From b39c271c965f5dccb9db3ffee6ffd3caca33e0cd Mon Sep 17 00:00:00 2001 From: koka Date: Tue, 1 Sep 2026 14:30:42 +0200 Subject: [PATCH] feat(backroom): add persistent lost-item dimension --- CHANGELOG.md | 12 + anotherworld/build.gradle | 34 +- .../anotherworld/AnotherWorldMod.java | 3 + .../src/main/resources/fabric.mod.json | 2 +- build.gradle | 95 +++--- gradle.properties | 4 +- .../sanctuary-backroom-data-v14.json | 38 +++ sanctuary/BACKROOM.md | 65 ++++ sanctuary/build.gradle | 106 +++++- .../sanctuary26/sanctuary/SanctuaryMod.java | 4 +- .../api/backroom/BackroomMaterialApi.java | 20 ++ .../backroom/BackroomChestService.java | 149 +++++++++ .../sanctuary/backroom/BackroomData.java | 316 ++++++++++++++++++ .../sanctuary/backroom/BackroomInventory.java | 108 ++++++ .../sanctuary/backroom/BackroomService.java | 240 +++++++++++++ .../sanctuary/mixin/BedBlockMixin.java | 26 ++ .../mixin/ChestBlockEntityMixin.java | 20 ++ .../sanctuary/mixin/ItemEntityMixin.java | 35 ++ .../mixin/ServerPlayerBackroomMixin.java | 44 +++ .../registry/SanctuaryRegistries.java | 13 + .../shop/DeliveryBoxBlockEntity.java | 58 ++++ .../sanctuary26/sanctuary/shop/ShopData.java | 8 +- .../sanctuary/world/SanctuaryDimensions.java | 4 +- .../sanctuary/worldgen/SanctuaryFeatures.java | 5 + .../feature/BackroomArchitectureFeature.java | 243 ++++++++++++++ .../assets/sanctuary/lang/en_us.json | 10 + .../assets/sanctuary/lang/fr_fr.json | 10 + .../assets/sanctuary/lang/ru_ru.json | 10 + .../worldgen/world_preset/normal.json | 8 + .../data/sanctuary/dimension/backroom.json | 11 + .../sanctuary/dimension_type/backroom.json | 41 +++ .../sanctuary/worldgen/biome/backroom.json | 32 ++ .../backroom_architecture.json | 4 + .../worldgen/noise_settings/backroom.json | 37 ++ .../placed_feature/backroom_architecture.json | 7 + .../worldgen/world_preset/overworld.json | 8 + .../worldgen/world_preset/sanctuary.json | 8 + sanctuary/src/main/resources/fabric.mod.json | 2 +- .../src/main/resources/sanctuary.mixins.json | 6 +- .../backroom/BackroomModelSmoke.java | 101 ++++++ 40 files changed, 1877 insertions(+), 70 deletions(-) create mode 100644 pack/migrations/sanctuary-backroom-data-v14.json create mode 100644 sanctuary/BACKROOM.md create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/BedBlockMixin.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ChestBlockEntityMixin.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ItemEntityMixin.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ServerPlayerBackroomMixin.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java create mode 100644 sanctuary/src/main/resources/data/sanctuary/dimension/backroom.json create mode 100644 sanctuary/src/main/resources/data/sanctuary/dimension_type/backroom.json create mode 100644 sanctuary/src/main/resources/data/sanctuary/worldgen/biome/backroom.json create mode 100644 sanctuary/src/main/resources/data/sanctuary/worldgen/configured_feature/backroom_architecture.json create mode 100644 sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/backroom.json create mode 100644 sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/backroom_architecture.json create mode 100644 sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomModelSmoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b522ddb..51f89f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve - Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46. - Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées. +## Non publié + +Modules modifiés : **Sanctuary `0.0.0-alpha.131`** et **Another World `0.0.0-alpha.57`**. + +- ajoute la dimension persistante `sanctuary:backroom`, accessible en s’accroupissant devant un lit et quittée uniquement par un lit, avec arrivées variées dans une zone architecturale de 2 000 blocs ; +- transfère atomiquement vers la Mailbox tout l’équipement d’entrée, y compris les rangées étendues, l’œuf compagnon et la cape, avec reprise idempotente après interruption ; +- conserve les objets perdus au dépop, dans le vide et lors des morts en Backroom dans un registre borné et non duplicable ; +- transforme tous les coffres de la dimension en butin renouvelable, pauvre la plupart du temps, qui privilégie les pertes personnelles dans les premiers coffres avant les pertes anciennes partagées ; +- génère des villes, bureaux, plaines, agrégats flottants et pièces visuellement retournées à partir de palettes composées, en réservant les matériaux d’Another World aux anomalies et les blocs précieux à de rares cachettes ; +- fait enregistrer la mousse turquoise par Another World via l’API publique de matériaux Backroom, sans dépendance circulaire ; +- migre Sanctuary vers les données v14 sans changer le protocole réseau 24, sans réinitialiser les sauvegardes ni effacer les constructions. + ## `26.2.0-alpha.226` Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225. diff --git a/anotherworld/build.gradle b/anotherworld/build.gradle index 031d722..45ce88a 100644 --- a/anotherworld/build.gradle +++ b/anotherworld/build.gradle @@ -1956,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") { "src/main/resources/assets/redstoner/textures/item/weather_gauge.png", "src/main/resources/data/redstoner/recipe/weather_gauge.json" ] - if (project.version.toString() != "0.0.0-alpha.56" + if (project.version.toString() != "0.0.0-alpha.57" || sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44" || sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2 || !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")') @@ -3273,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") { "pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json")) def mixer = file( "src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text - if (project.version.toString() != "0.0.0-alpha.56" + if (project.version.toString() != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.188" || migration.target?.pack_version != "26.2.0-alpha.189" || migration.target?.modules?.anotherworld != "0.0.0-alpha.48" @@ -3300,3 +3300,33 @@ tasks.register("verifyClimateBiomeMixer") { tasks.named("check") { dependsOn tasks.named("verifyClimateBiomeMixer") } + +tasks.register("verifyBackroomMaterialIntegration") { + group = "verification" + description = "Checks the one-way Another World material integration with Sanctuary Backroom." + inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java")) + inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java")) + inputs.file(rootProject.file( + "sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java")) + doLast { + def initializer = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java").text + def blocks = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java").text + def api = rootProject.file( + "sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text + def rootBuild = rootProject.file("build.gradle").text + def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json")) + if (project.version.toString() != "0.0.0-alpha.57" + || !initializer.contains("BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS)") + || !blocks.contains('"turquoise_moss"') + || !api.contains("public static void registerAnomalyMaterial") + || manifest.depends?.sanctuary != ">=0.0.0-alpha.131" + || !rootBuild.contains('project(":anotherworld")') + || !rootBuild.contains('implementation project(":sanctuary")')) { + throw new GradleException("Another World alpha.57 Backroom material integration is incomplete") + } + } +} + +tasks.named("check") { + dependsOn tasks.named("verifyBackroomMaterialIntegration") +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java index db6f8f3..e85e369 100644 --- a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java @@ -4,7 +4,9 @@ import fr.koka99cab.canon.CanonMod; import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem; import fr.koka99cab.sanctuary26.anotherworld.network.AnotherWorldNetworking; import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldRegistries; +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks; import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService; +import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi; import net.fabricmc.api.ModInitializer; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -26,6 +28,7 @@ public final class AnotherWorldMod implements ModInitializer { @Override public void onInitialize() { AnotherWorldRegistries.initialize(); + BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS); VaultService.initialize(); WeatherCompassItem.registerFrameInteraction(); CanonMod.initialize(); diff --git a/anotherworld/src/main/resources/fabric.mod.json b/anotherworld/src/main/resources/fabric.mod.json index 5a2aae1..c021db4 100644 --- a/anotherworld/src/main/resources/fabric.mod.json +++ b/anotherworld/src/main/resources/fabric.mod.json @@ -31,7 +31,7 @@ "java": ">=25", "fabric-api": ">=0.154.2+26.2", "ambiance": "*", - "sanctuary": ">=0.0.0-alpha.117" + "sanctuary": ">=0.0.0-alpha.131" }, "custom": { "sanctuary26": { diff --git a/build.gradle b/build.gradle index 5cd5e2a..9545b8a 100644 --- a/build.gradle +++ b/build.gradle @@ -2916,11 +2916,11 @@ tasks.register("verifyNotchEncounter") { def textureHash = MessageDigest.getInstance("SHA-256").digest(texture.bytes).encodeHex().toString() if (rootProject.itsalive_version != "0.0.0-alpha.38" || rootProject.ouch_version != "0.0.0-alpha.11" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || rootProject.masterkey_version != "0.0.0-alpha.9" || rootProject.pack_version != "26.2.0-alpha.226" || manifests.itsalive.custom?.sanctuary26?.data_version != 4 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.ouch.depends?.itsalive != ">=0.0.0-alpha.37" || !registry.contains('Identifier.fromNamespaceAndPath("portals", "notch")') || !resident.contains("setCanonicalUuid(canonical.getUUID())") @@ -3010,7 +3010,7 @@ tasks.register("verifyMagicCarpet") { if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.iliketomoveit_version != "0.0.0-alpha.18" || rootProject.onlyfun_version != "0.0.0-alpha.27" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4 || manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5 || manifests.iliketomoveit.depends?.sanctuary != ">=0.0.0-alpha.127" @@ -3018,7 +3018,7 @@ tasks.register("verifyMagicCarpet") { || manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3 || manifests.onlyfun.depends?.iliketomoveit != ">=0.0.0-alpha.17" || manifests.onlyfun.depends?.sanctuary != ">=0.0.0-alpha.129" - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || migration.status != "released" || migration.source?.pack_version != "26.2.0-alpha.223" @@ -3641,7 +3641,7 @@ tasks.register("verifyProgressivePauseMenuRelease") { "sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))] } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || rootProject.onlyfun_version != "0.0.0-alpha.27" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.modules != [sanctuary: "0.0.0-alpha.108", onlyfun: "0.0.0-alpha.17"] @@ -3691,7 +3691,7 @@ tasks.register("verifyMirrorLavenderDrawerRelease") { "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/registry/RedStonerRegistries.java").text def drawerRecipe = file("redstoner/src/main/resources/data/redstoner/recipe/drawer.json") if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || rootProject.redstoner_version != "0.0.0-alpha.13" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.191" @@ -4484,7 +4484,7 @@ tasks.register("verifyShopPricingLayoutRelease") { def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.onlyfun_version != "0.0.0-alpha.27" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.192" || migration.target?.pack_version != "26.2.0-alpha.193" @@ -4578,7 +4578,7 @@ tasks.register("verifyShopCsvBossGatesRelease") { def offerCatalog = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.onlyfun_version != "0.0.0-alpha.27" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.194" || migration.source?.modules != [onlyfun: "0.0.0-alpha.18", sanctuary: "0.0.0-alpha.110"] @@ -4671,7 +4671,7 @@ tasks.register("verifyQuestRequestsCauldronBatchesRelease") { def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.itsalive_version != "0.0.0-alpha.38" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.196" || migration.target?.pack_version != "26.2.0-alpha.197" || migration.target?.modules != [itsalive: "0.0.0-alpha.29", sanctuary: "0.0.0-alpha.113"] @@ -4771,7 +4771,7 @@ tasks.register("verifyQuestRequestContractsRelease") { def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.itsalive_version != "0.0.0-alpha.38" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.198" || migration.source?.modules?.sanctuary != "0.0.0-alpha.114" || migration.target?.pack_version != "26.2.0-alpha.199" @@ -4891,7 +4891,7 @@ tasks.register("verifyQuestRequestEscrowRelease") { def packwizIndex = file("pack/packwiz/index.toml").text def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.200" || migration.source?.modules?.sanctuary != "0.0.0-alpha.115" || migration.target?.pack_version != "26.2.0-alpha.201" @@ -4903,7 +4903,7 @@ tasks.register("verifyQuestRequestEscrowRelease") { || migration.quest_request_escrow?.kill_progress_automatic != true || migration.quest_request_escrow?.reward_delivery_to_claimant_mailbox != true || migration.compatibility?.sanctuary_target_network_protocol != 21 - || sanctuaryManifest.custom?.sanctuary26?.data_version != 13 + || sanctuaryManifest.custom?.sanctuary26?.data_version != 14 || sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24 || release.pack_version != "26.2.0-alpha.226" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.226.mrpack" @@ -4945,10 +4945,10 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") { def sanctuaryManifest = new JsonSlurper().parse(file( "sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || rootProject.itsalive_version != "0.0.0-alpha.38" || rootProject.iliketomoveit_version != "0.0.0-alpha.18" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.201" || migration.source?.modules != [ itsalive: "0.0.0-alpha.30", @@ -4982,7 +4982,7 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") { || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || moveItManifest.custom?.sanctuary26?.data_version != 4 || moveItManifest.custom?.sanctuary26?.network_protocol != 5 - || sanctuaryManifest.custom?.sanctuary26?.data_version != 13 + || sanctuaryManifest.custom?.sanctuary26?.data_version != 14 || sanctuaryManifest.custom?.sanctuary26?.network_protocol != 24 || release.pack_version != "26.2.0-alpha.226" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.226.mrpack" @@ -5105,8 +5105,8 @@ tasks.register("verifySafeVaultFeature") { image == null || image.width != size[0] || image.height != size[1] }.keySet() if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.anotherworld_version != "0.0.0-alpha.57" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.schema_version != 1 || migration.source?.pack_version != "26.2.0-alpha.201" || migration.target?.pack_version != "26.2.0-alpha.202" @@ -5226,7 +5226,7 @@ tasks.register("verifySafeVaultFeature") { || unlimitedMigration.compatibility?.registry_ids_changed != false || unlimitedMigration.compatibility?.pack_release_updated != true || unlimitedMigration.distribution?.packwiz_index_refreshed != true - || manifest.depends?.sanctuary != ">=0.0.0-alpha.117" + || manifest.depends?.sanctuary != ">=0.0.0-alpha.131" || manifest.custom?.sanctuary26?.data_version != 5 || manifest.custom?.sanctuary26?.network_protocol != 2 || !file("build.gradle").text.contains('implementation project(":sanctuary")') @@ -5297,7 +5297,7 @@ tasks.register("verifySafeVaultInterfaceAlpha203Release") { def packwizIndex = file("pack/packwiz/index.toml").text def jeiFiles = prism.files.findAll { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.202" || migration.source?.modules != [anotherworld: "0.0.0-alpha.50"] || migration.target?.pack_version != "26.2.0-alpha.203" @@ -5343,7 +5343,7 @@ tasks.register("verifySafeVaultFixAlpha204Release") { def packwizIndex = file("pack/packwiz/index.toml").text def jeiFiles = prism.files.findAll { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.203" || migration.source?.modules != [anotherworld: "0.0.0-alpha.51"] || migration.target?.pack_version != "26.2.0-alpha.204" @@ -5397,7 +5397,7 @@ tasks.register("verifySafeVaultCubeAlpha205Release") { def packwizIndex = file("pack/packwiz/index.toml").text def jeiFiles = prism.files.findAll { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.204" || migration.source?.modules != [anotherworld: "0.0.0-alpha.52"] || migration.target?.pack_version != "26.2.0-alpha.205" @@ -5455,7 +5455,7 @@ tasks.register("verifySafeVaultCtmAlpha206Release") { def packwizIndex = file("pack/packwiz/index.toml").text def jeiFiles = prism.files.findAll { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.205" || migration.source?.modules != [anotherworld: "0.0.0-alpha.53"] || migration.target?.pack_version != "26.2.0-alpha.206" @@ -5507,7 +5507,7 @@ tasks.register("verifySafeVaultUnlimitedAlpha207Release") { def packwizIndex = file("pack/packwiz/index.toml").text def jeiFiles = prism.files.findAll { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.anotherworld_version != "0.0.0-alpha.56" + || rootProject.anotherworld_version != "0.0.0-alpha.57" || migration.source?.pack_version != "26.2.0-alpha.206" || migration.source?.modules != [anotherworld: "0.0.0-alpha.54"] || migration.target?.pack_version != "26.2.0-alpha.207" @@ -5966,7 +5966,7 @@ tasks.register("verifyAlpha210Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || rootProject.onlyfun_version != "0.0.0-alpha.27" || rootProject.iliketomoveit_version != "0.0.0-alpha.18" || rootProject.itsalive_version != "0.0.0-alpha.38" @@ -6003,7 +6003,7 @@ tasks.register("verifyAlpha210Release") { || migration.distribution?.jei?.target != "30.28.0.193" || migration.distribution?.jei?.modrinth_version_id != "qaCQ7LGO" || migration.distribution?.jei?.packwiz_and_prism_synchronized != true - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || manifests.onlyfun.custom?.sanctuary26?.data_version != 2 || manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3 @@ -6068,9 +6068,9 @@ tasks.register("verifyAlpha211Release") { itsalive: "0.0.0-alpha.38", ouch: "0.0.0-alpha.11", masterkey: "0.0.0-alpha.9"] - def workspaceModules = currentModules + def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.131"] def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11] - def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 13] + def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 14] def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22] def workspaceProtocols = expectedProtocols + [sanctuary: 24] def manifests = expectedTarget.collectEntries { id, version -> @@ -6168,7 +6168,7 @@ tasks.register("verifyAlpha212Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.211" || migration.source?.modules != [sanctuary: "0.0.0-alpha.119"] || migration.target?.pack_version != "26.2.0-alpha.212" @@ -6186,7 +6186,7 @@ tasks.register("verifyAlpha212Release") { || migration.distribution?.packwiz_index_refreshed != true || migration.distribution?.prism_modrinth_index_updated != true || migration.distribution?.shared_client_server_mrpack != true - || manifest.custom?.sanctuary26?.data_version != 13 + || manifest.custom?.sanctuary26?.data_version != 14 || manifest.custom?.sanctuary26?.network_protocol != 24 || !minecraftMixin.contains('method = "pickBlockOrEntity"') || release.pack_version != "26.2.0-alpha.226" @@ -6667,7 +6667,7 @@ tasks.register("verifyAlpha218Release") { } || rootProject.onlyfun_version != "0.0.0-alpha.27" || rootProject.iliketomoveit_version != "0.0.0-alpha.18" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.217" || migration.source?.modules != sourceModules || migration.target?.pack_version != "26.2.0-alpha.218" @@ -6691,7 +6691,7 @@ tasks.register("verifyAlpha218Release") { || manifests.redstoner.custom?.sanctuary26?.network_protocol != 7 || manifests.onlyfun.custom?.sanctuary26?.data_version != 2 || manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || !rules.contains("MAX_CONTAINERS = 128") || !rules.contains("PAGE_SIZE = 54") @@ -6753,7 +6753,7 @@ tasks.register("verifyAlpha219Release") { ambiance: "0.0.0-alpha.22", sanctuary: "0.0.0-alpha.130", iliketomoveit: "0.0.0-alpha.18", onlyfun: "0.0.0-alpha.27", itsalive: "0.0.0-alpha.38", ouch: "0.0.0-alpha.11", masterkey: "0.0.0-alpha.9"] - def workspaceModules = currentModules + def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.131"] def requiredJars = currentModules.collect { id, version -> "file = \"mods/${id}-${version}.jar\"" } @@ -6793,7 +6793,7 @@ tasks.register("verifyAlpha219Release") { || migration.compatibility?.iliketomoveit_network_protocol != 5 || migration.compatibility?.ambiance_data_version != 2 || migration.compatibility?.ambiance_network_protocol != 6 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4 || manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5 @@ -7078,7 +7078,7 @@ tasks.register("verifyAlpha222Release") { } if (rootProject.pack_version != "26.2.0-alpha.226" || rootProject.redstoner_version != "0.0.0-alpha.13" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.221" || migration.source?.modules != [ redstoner: "0.0.0-alpha.12", sanctuary: "0.0.0-alpha.122"] @@ -7120,7 +7120,7 @@ tasks.register("verifyAlpha222Release") { || migration.distribution?.jei?.packwiz_and_prism_synchronized != true || manifests.redstoner.custom?.sanctuary26?.data_version != 3 || manifests.redstoner.custom?.sanctuary26?.network_protocol != 7 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || !rules.contains("MAX_SEARCH_LENGTH = 32") || !rules.contains("compactCount(int count)") @@ -7236,7 +7236,7 @@ tasks.register("verifyAlpha223Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.222" || migration.source?.modules != [sanctuary: "0.0.0-alpha.124"] || migration.target?.pack_version != "26.2.0-alpha.223" @@ -7272,7 +7272,7 @@ tasks.register("verifyAlpha223Release") { || migration.distribution?.jei?.target != "30.29.0.198" || migration.distribution?.jei?.modrinth_version_id != "67Dvn8sL" || migration.distribution?.jei?.packwiz_and_prism_synchronized != true - || manifest.custom?.sanctuary26?.data_version != 13 + || manifest.custom?.sanctuary26?.data_version != 14 || manifest.custom?.sanctuary26?.network_protocol != 24 || !inventorySort.contains("COOLDOWN_TICKS = 8") || !inventorySort.contains("if (row == 0) continue") @@ -7357,6 +7357,7 @@ tasks.register("verifyAlpha224Release") { def currentModules = targetModules + [ itsalive: "0.0.0-alpha.38", iliketomoveit: "0.0.0-alpha.18", onlyfun: "0.0.0-alpha.27", sanctuary: "0.0.0-alpha.130"] + def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.131"] def inventorySort = file( "sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java").text def clientProgress = file( @@ -7376,7 +7377,7 @@ tasks.register("verifyAlpha224Release") { "file = \"mods/${id}-${version}.jar\"" } if (rootProject.pack_version != "26.2.0-alpha.226" - || currentModules.any { id, version -> rootProject.property("${id}_version") != version } + || workspaceModules.any { id, version -> rootProject.property("${id}_version") != version } || migration.status != "released" || migration.source?.pack_version != "26.2.0-alpha.223" || migration.source?.modules != sourceModules @@ -7407,7 +7408,7 @@ tasks.register("verifyAlpha224Release") { || migration.distribution?.prism_modrinth_index_updated != true || manifests.itsalive.custom?.sanctuary26?.data_version != 4 || manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 13 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 14 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 24 || !inventorySort.contains("instanceof ChestMenu") || !inventorySort.contains("instanceof ShulkerBoxMenu") @@ -7477,10 +7478,14 @@ tasks.register("verifyAlpha225Release") { iliketomoveit: "0.0.0-alpha.18", onlyfun: "0.0.0-alpha.27", sanctuary: "0.0.0-alpha.129"] def currentModules = targetModules + [sanctuary: "0.0.0-alpha.130"] + def currentWorkspaceModules = currentModules + [ + anotherworld: "0.0.0-alpha.57", sanctuary: "0.0.0-alpha.131"] def workspaceModules = moduleIds.collectEntries { id -> [(id): project(":${id}").version.toString()] } - def requiredJars = workspaceModules.collect { id, version -> + def releasedModules = workspaceModules + [ + anotherworld: "0.0.0-alpha.56", sanctuary: "0.0.0-alpha.130"] + def requiredJars = releasedModules.collect { id, version -> "file = \"mods/${id}-${version}.jar\"" } def obsoleteJars = sourceModules.collect { id, version -> @@ -7518,7 +7523,7 @@ tasks.register("verifyAlpha225Release") { def carpetTextureHash = MessageDigest.getInstance("SHA-256") .digest(carpetTexture.bytes).encodeHex().toString() if (rootProject.pack_version != "26.2.0-alpha.226" - || currentModules.any { id, version -> rootProject.property("${id}_version") != version } + || currentWorkspaceModules.any { id, version -> rootProject.property("${id}_version") != version } || migration.status != "released" || migration.source?.pack_version != "26.2.0-alpha.224" || migration.source?.modules != sourceModules @@ -7639,11 +7644,13 @@ tasks.register("verifyAlpha226Release") { def workspaceModules = moduleIds.collectEntries { id -> [(id): project(":${id}").version.toString()] } - def requiredJars = workspaceModules.collect { id, version -> + def releasedModules = workspaceModules + [ + anotherworld: "0.0.0-alpha.56", sanctuary: "0.0.0-alpha.130"] + def requiredJars = releasedModules.collect { id, version -> "file = \"mods/${id}-${version}.jar\"" } if (rootProject.pack_version != "26.2.0-alpha.226" - || rootProject.sanctuary_version != "0.0.0-alpha.130" + || rootProject.sanctuary_version != "0.0.0-alpha.131" || migration.status != "released" || migration.source?.pack_version != "26.2.0-alpha.225" || migration.source?.modules != [sanctuary: "0.0.0-alpha.129"] @@ -7660,7 +7667,7 @@ tasks.register("verifyAlpha226Release") { || migration.compatibility?.existing_saves_supported != true || migration.compatibility?.new_world_required != false || migration.compatibility?.save_reset_required != false - || manifest.custom?.sanctuary26?.data_version != 13 + || manifest.custom?.sanctuary26?.data_version != 14 || manifest.custom?.sanctuary26?.network_protocol != 24 || baseRecipe.type != "minecraft:crafting_shaped" || baseRecipe.pattern != ["WW", "WW", "WW"] diff --git a/gradle.properties b/gradle.properties index 06bbc13..0049830 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ ambiance_version=0.0.0-alpha.22 ambiance_lifecycle=active redstoner_version=0.0.0-alpha.13 redstoner_lifecycle=active -anotherworld_version=0.0.0-alpha.56 +anotherworld_version=0.0.0-alpha.57 anotherworld_lifecycle=active itsalive_version=0.0.0-alpha.38 itsalive_lifecycle=active @@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.18 iliketomoveit_lifecycle=active onlyfun_version=0.0.0-alpha.27 onlyfun_lifecycle=active -sanctuary_version=0.0.0-alpha.130 +sanctuary_version=0.0.0-alpha.131 sanctuary_lifecycle=active pack_version=26.2.0-alpha.226 maven_group=fr.koka99cab.sanctuary26 diff --git a/pack/migrations/sanctuary-backroom-data-v14.json b/pack/migrations/sanctuary-backroom-data-v14.json new file mode 100644 index 0000000..f22942b --- /dev/null +++ b/pack/migrations/sanctuary-backroom-data-v14.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "status": "pending_release", + "module": "sanctuary", + "source_module_version": "0.0.0-alpha.130", + "target_module_version": "0.0.0-alpha.131", + "integration_modules": { + "anotherworld": { + "source": "0.0.0-alpha.56", + "target": "0.0.0-alpha.57", + "data_version_changed": false, + "network_protocol_changed": false + } + }, + "source_data_version": 13, + "target_data_version": 14, + "migration": { + "kind": "monotone_addition", + "save_key": "sanctuary:backroom", + "existing_player_progress_preserved": true, + "existing_shop_data_preserved": true, + "existing_mailboxes_preserved": true, + "mailbox_transaction_history_default": [], + "backroom_loss_registry_default": [], + "backroom_chest_registry_default": [] + }, + "compatibility": { + "existing_saves_supported": true, + "new_world_required": false, + "save_reset_required": false, + "network_protocol_changed": false, + "registry_ids_added": [ + "sanctuary:backroom", + "sanctuary:backroom_loss_id", + "sanctuary:backroom_architecture" + ] + } +} diff --git a/sanctuary/BACKROOM.md b/sanctuary/BACKROOM.md new file mode 100644 index 0000000..f7ef570 --- /dev/null +++ b/sanctuary/BACKROOM.md @@ -0,0 +1,65 @@ +# Backroom + +La Backroom est une dimension persistante de récupération et de construction. Elle n’est jamais +réinitialisée : routes, bases, coffres et bâtiments posés par les habitants restent dans le monde. +Sa zone architecturale forme un disque de 2 000 blocs autour de l’origine. + +## Entrer et sortir + +- S’accroupir et utiliser un lit hors de la Backroom déclenche l’entrée, sans remplacer l’usage + normal du lit. +- Avant toute téléportation, le serveur transfère atomiquement vers la Mailbox enregistrée les + 43 emplacements vanilla, les 18 emplacements étendus, l’œuf compagnon et la cape équipée. +- Si aucune Mailbox extérieure n’est chargée, ou si elle ne peut pas recevoir l’intégralité du + colis, rien ne bouge et l’entrée est refusée. Une Mailbox construite dans la Backroom n’est jamais + une destination d’entrée et ne permet donc pas d’importer du matériel. +- L’identifiant de transaction est conservé par la Mailbox. Après une interruption, la connexion + suivante termine exactement une fois le vidage correspondant ou annule une transaction qui n’a + jamais atteint la Mailbox. +- Dans la Backroom, tout lit — généré ou posé par un joueur — ramène au lit de réapparition normal, + ou au point d’apparition global s’il n’existe plus. Le joueur conserve alors ses trouvailles. +- Aucun autre usage de lit ne définit un point de réapparition dans la dimension. +- Toute autre téléportation vers la Backroom est refusée. Une téléportation forcée vers l’extérieur + reste possible pour l’administration, mais replace d’abord le butin porté dans le registre : elle + ne constitue jamais une extraction réussie. + +## Objets perdus et coffres + +Les piles qui disparaissent naturellement ou tombent dans le vide rejoignent un registre mondial +borné. Une mort de joueur dans la Backroom y replace aussi tout ce qu’il transportait. Chaque pile +réelle matérialisée reçoit un identifiant persistant : elle peut passer du registre à un coffre, puis +à un joueur et revenir au registre, mais elle ne peut jamais exister deux fois. +Une perte associée à un joueur lui reste réservée pendant 24 000 ticks avant de pouvoir rejoindre +les tirages historiques partagés ; une perte sans propriétaire est immédiatement partagée. + +Tous les coffres vanilla de la Backroom, générés ou posés, deviennent des nœuds de butin renouvelés +toutes les 12 000 ticks lors de leur prochaine ouverture. Le renouvellement rend au registre les +objets perdus non pris, efface le contenu ordinaire et produit le plus souvent du vide ou des +ressources pauvres : terre, pierre, planches, torches, nourriture et outils simples. Le butin ancien +partagé et les objets de valeur restent rares. Reposer un coffre au même emplacement pendant le +même cycle ne crée pas un nouveau tirage ; multiplier les emplacements donne en revanche bien des +tirages indépendants, au prix des ressources trouvées ou produites sur place. + +Pendant une expédition, chaque coffre distinct est un essai personnel. Le premier a 75 % de chance +de proposer une perte récente du joueur, le deuxième 90 %, le troisième 100 %, puis les suivants +65 %. Sans perte personnelle disponible, le coffre suit simplement son tirage général. + +## Architecture + +Les plans sont déterministes par grandes zones et utilisent cinq familles composées : + +- mégalopoles en béton, verre, pierre et voirie ; +- bureaux superposés en bois, clay, textile et éléments techniques ; +- plaines naturelles avec arbres et rares ruines ; +- agrégats de plateformes et de pièces flottantes reliées verticalement ; +- pièces construites visuellement à 90° ou à l’envers, sans modifier la gravité globale. + +Les matériaux très marqués d’Another World n’apparaissent que ponctuellement aux anomalies. Aucun +bloc précieux ne sert de matériau structurel ; de très rares blocs de diamant sont cachés dans les +fondations. + +## Exploitation + +Les données `sanctuary:backroom` utilisent leur propre `data_version`, ainsi que des limites pour les +transactions, pertes, coffres suivis, visites et arrivées récentes. Les opérateurs disposent de +`/backroom status` et `/backroom inspect ` pour contrôler les registres sans les modifier. diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index 7c17862..218f021 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -156,6 +156,14 @@ tasks.register("notchVictoryStateSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.data.NotchVictoryStateSmoke" } +tasks.register("backroomModelSmoke", JavaExec) { + group = "verification" + description = "Checks bounded Backroom entry data, personal loot priority and composed architecture zones." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomModelSmoke" +} + tasks.register("verifyBlackMarket") { group = "verification" description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery." @@ -191,7 +199,7 @@ tasks.register("verifyBlackMarket") { def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2") || !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing") || !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5") @@ -1200,13 +1208,13 @@ tasks.register("verifySanctuary") { } def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json")) - if (manifest.custom?.sanctuary26?.data_version != 13 + if (manifest.custom?.sanctuary26?.data_version != 14 || manifest.custom?.sanctuary26?.network_protocol != 24 || manifest.depends?.jei != ">=30.24.0.176" || manifest.depends?."fabric-api" != ">=0.155.0+26.2" || !manifest.entrypoints?.client?.contains("fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient") || !manifest.mixins?.contains("sanctuary.mixins.json")) { - throw new GradleException("Sanctuary must expose data v13, protocol 24, JEI and the client entrypoint") + throw new GradleException("Sanctuary must expose data v14, protocol 24, JEI and the client entrypoint") } def capeMigration = new JsonSlurper().parse(rootProject.file( "pack/migrations/26.2.0-alpha.155-cape-equipment-to-alpha.156.json")) @@ -1691,8 +1699,8 @@ tasks.register("verifySanctuary") { def worldgenRoot = file("src/main/resources/data/sanctuary/worldgen") def worldgenCounts = [ - density_function: 12, biome: 6, configured_feature: 13, configured_carver: 1, - placed_feature: 19, noise_settings: 3, world_preset: 2 + density_function: 12, biome: 7, configured_feature: 14, configured_carver: 1, + placed_feature: 20, noise_settings: 4, world_preset: 2 ] worldgenCounts.each { type, expected -> def actual = fileTree(new File(worldgenRoot, type.toString())).matching { include "**/*.json" }.files.size() @@ -2907,7 +2915,7 @@ tasks.register("verifyShopCurrencyRowsRelease") { "src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text def shop = file( "src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.117" || migration.shop_currency_inventory?.all_unlocked_rows_counted != true || migration.shop_currency_inventory?.overflow_rows_counted != true @@ -3073,7 +3081,7 @@ tasks.register("verifyProgressivePauseMenu") { "pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json")) def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.pack_version != "26.2.0-alpha.191" || migration.target?.modules?.sanctuary != "0.0.0-alpha.109" || migration.pause_menu?.progression_full_width != true @@ -3111,7 +3119,7 @@ tasks.register("verifyShopPricingLayout") { "pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json")) def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.110" || migration.shop_layout?.locked_offer_buttons != 6 || migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9] @@ -3142,7 +3150,7 @@ tasks.register("verifyShopCsvBossGates") { "pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json")) def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.pack_version != "26.2.0-alpha.195" || migration.target?.modules?.sanctuary != "0.0.0-alpha.111" || migration.compatibility?.sanctuary_data_version != 11 @@ -3173,7 +3181,7 @@ tasks.register("verifyDeliveryBoxOrientation") { def blockstate = new JsonSlurper().parse( file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json")) def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet() - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.112" || migration.mailbox?.id != "sanctuary:delivery_box" || migration.mailbox?.horizontal_facing != true @@ -3201,7 +3209,7 @@ tasks.register("verifyInventoryShiftClick") { def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text def gui = file("INVENTORY_GUI.md").text - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || !transfer.contains("shouldAppendOverflow") || !transfer.contains("isVanillaStorageRange") || !transfer.contains("hotbarStart") @@ -3247,7 +3255,7 @@ tasks.register("verifyQuestRequests") { new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json")) .pools[0].entries[0].name } - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.113" || migration.quest_requests?.save_key != "quest_requests" || migration.quest_requests?.data_version != 1 @@ -3305,7 +3313,7 @@ tasks.register("verifyQuestRequestContracts") { def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale -> [(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))] } - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.198" || migration.source?.modules?.sanctuary != "0.0.0-alpha.114" || migration.target?.pack_version != "26.2.0-alpha.199" @@ -3374,7 +3382,7 @@ tasks.register("verifyQuestRequestEscrow") { def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale -> [(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))] } - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.source?.pack_version != "26.2.0-alpha.200" || migration.source?.modules?.sanctuary != "0.0.0-alpha.115" || migration.target?.pack_version != "26.2.0-alpha.201" @@ -3453,7 +3461,7 @@ tasks.register("verifyCraftableBannerCape") { def locales = ["fr_fr", "en_us", "ru_ru"].collect { new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json")) } - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.130" || migration.white_cape?.crafting_material_count != 6 || migration.white_cape?.maximum_pattern_layers != 6 @@ -3508,7 +3516,7 @@ tasks.register("verifyAlphaIndev") { def locales = ["fr_fr", "en_us", "ru_ru"].collect { new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json")) } - if (project.version.toString() != "0.0.0-alpha.130" + if (project.version.toString() != "0.0.0-alpha.131" || migration.target?.modules?.sanctuary != "0.0.0-alpha.122" || migration.alpha_world?.dimension != "sanctuary:alpha" || migration.alpha_world?.legacy_dimension != "portals:alpha" @@ -3530,7 +3538,73 @@ tasks.register("verifyAlphaIndev") { } } +tasks.register("verifyBackroom") { + group = "verification" + description = "Checks Backroom dimension, migration, Mailbox boundary, loss registry and translations." + inputs.file(file("BACKROOM.md")) + inputs.file(rootProject.file("pack/migrations/sanctuary-backroom-data-v14.json")) + inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom")) + inputs.files(fileTree("src/main/resources/data/sanctuary") { include "**/*backroom*.json" }) + inputs.files(fileTree("src/main/resources/assets/sanctuary/lang") { include "*.json" }) + inputs.files( + file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java"), + file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java"), + file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java"), + file("src/main/resources/data/minecraft/worldgen/world_preset/normal.json"), + file("src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json"), + file("src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json")) + doLast { + def migration = new JsonSlurper().parse(rootProject.file("pack/migrations/sanctuary-backroom-data-v14.json")) + def dimensionType = new JsonSlurper().parse(file("src/main/resources/data/sanctuary/dimension_type/backroom.json")) + def presets = [ + file("src/main/resources/data/minecraft/worldgen/world_preset/normal.json"), + file("src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json"), + file("src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json") + ].collect { new JsonSlurper().parse(it) } + def data = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java").text + def inventory = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java").text + def service = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java").text + def chest = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java").text + def mailbox = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java").text + def architecture = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java").text + def materialApi = file( + "src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text + def locales = ["fr_fr", "en_us", "ru_ru"].collect { + new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json")) + } + if (project.version.toString() != "0.0.0-alpha.131" + || migration.source_data_version != 13 || migration.target_data_version != 14 + || migration.integration_modules?.anotherworld?.source != "0.0.0-alpha.56" + || migration.integration_modules?.anotherworld?.target != "0.0.0-alpha.57" + || migration.compatibility?.network_protocol_changed != false + || presets.any { it.dimensions?.get("sanctuary:backroom") == null } + || dimensionType.attributes?.get("minecraft:gameplay/bed_rule")?.can_set_spawn != "never" + || dimensionType.attributes?.get("minecraft:gameplay/bed_rule")?.can_sleep != "never" + || !data.contains("MAX_LOST_ITEMS = 16_384") || !data.contains("MAX_CHEST_STATES = 65_536") + || !data.contains("PERSONAL_RESERVATION_TICKS = 24_000L") + || !data.contains("indexOfLoss(marker)") || !data.contains("BACKROOM_LOSS_COMPONENT") + || !inventory.contains("companionEgg") || !inventory.contains("equippedCape") + || !service.contains("ARRIVAL_RADIUS = 2_000") || !service.contains("insertTransaction") + || !service.contains("AUTHORIZED_TRANSITIONS") + || !service.contains("level -> !isBackroom(level)") + || !mailbox.contains("MAX_PROCESSED_TRANSACTIONS = 128") + || !chest.contains("REFRESH_TICKS = 12_000L") || !chest.contains("ordinal == 3") + || !architecture.contains("PLAYABLE_RADIUS = 2_000") + || !architecture.contains("BackroomMaterialApi.anomalyMaterial()") + || !materialApi.contains("registerAnomalyMaterial") + || locales.any { !it.containsKey("dimension.sanctuary.backroom") + || !it.containsKey("message.sanctuary.backroom.bed_entry_only") + || !it.containsKey("message.sanctuary.backroom.registry_busy") + || !it.containsKey("message.sanctuary.backroom.mailbox_full") + || !it.containsKey("message.sanctuary.backroom.exited_with_losses") }) { + throw new GradleException("Sanctuary alpha.131 Backroom contract is incomplete") + } + } +} + tasks.named("check") { + dependsOn tasks.named("backroomModelSmoke") + dependsOn tasks.named("verifyBackroom") dependsOn tasks.named("notchVictoryStateSmoke") dependsOn tasks.named("verifySanctuary") dependsOn tasks.named("verifyDeliveryBoxOrientation") diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java index 4f1b24b..6a9a0f7 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java @@ -26,6 +26,7 @@ import fr.koka99cab.sanctuary26.sanctuary.shop.ShopService; import fr.koka99cab.sanctuary26.sanctuary.shop.BlackMarketService; import fr.koka99cab.sanctuary26.sanctuary.shop.QuestBoardService; import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService; +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService; import net.fabricmc.api.ModInitializer; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -34,7 +35,7 @@ import org.slf4j.LoggerFactory; public final class SanctuaryMod implements ModInitializer { public static final String MOD_ID = "sanctuary"; public static final String DISPLAY_NAME = "Sanctuary"; - public static final int CURRENT_DATA_VERSION = 13; + public static final int CURRENT_DATA_VERSION = 14; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public SanctuaryMod() { @@ -53,6 +54,7 @@ public final class SanctuaryMod implements ModInitializer { SanctuarySpawn.register(); SanctuaryWorldProfileData.register(); SanctuaryRegistries.initialize(); + BackroomService.initialize(); SanctuaryBulkActions.initialize(); SanctuaryInventoryCapacity.initialize(); SanctuaryHotbarRows.initialize(); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java new file mode 100644 index 0000000..a406d55 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java @@ -0,0 +1,20 @@ +package fr.koka99cab.sanctuary26.sanctuary.api.backroom; + +import java.util.Objects; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; + +/** Public, one-way integration point for rare materials owned by other modules. */ +public final class BackroomMaterialApi { + private static Block anomalyMaterial = Blocks.WARPED_WART_BLOCK; + + private BackroomMaterialApi() {} + + public static void registerAnomalyMaterial(Block material) { + anomalyMaterial = Objects.requireNonNull(material, "Backroom anomaly material"); + } + + public static Block anomalyMaterial() { + return anomalyMaterial; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java new file mode 100644 index 0000000..c832355 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomChestService.java @@ -0,0 +1,149 @@ +package fr.koka99cab.sanctuary26.sanctuary.backroom; + +import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.ChestType; + +/** Renewable loot nodes backed by the non-duplicating real-item registry. */ +public final class BackroomChestService { + public static final long REFRESH_TICKS = 12_000L; + + private BackroomChestService() {} + + public static void open(ChestBlockEntity opened, ServerPlayer player) { + if (!(opened.getLevel() instanceof ServerLevel level) || !BackroomService.isBackroom(level)) return; + ChestGroup group = group(level, opened); + BackroomData data = BackroomData.get(level.getServer()); + long cycle = Math.floorDiv(level.getGameTime(), REFRESH_TICKS); + BackroomData.ChestState state = data.chest(group.anchor).orElse(null); + ArrayList assigned = state == null ? new ArrayList<>() : new ArrayList<>(state.assignedIds()); + boolean refreshed = state == null || state.cycle() != cycle; + + if (refreshed) { + data.reconcileChest(group.anchor, markers(group)); + group.clear(); + assigned.clear(); + } + + BackroomData.ChestVisit visit = data.visitChest(player.getUUID(), group.anchor); + boolean personalInserted = false; + if (visit.firstVisit() && personalRoll(player.getRandom(), visit.ordinal())) { + BackroomData.MaterializedLoss loss = data.takePersonal(player.getUUID(), group.anchor).orElse(null); + if (loss != null && group.insertLoss(loss.stack())) { + assigned.add(loss.id()); + personalInserted = true; + } else if (loss != null) { + data.capture(loss.stack(), player.getUUID(), "chest_full", level.getGameTime()); + } + } + if (refreshed) rollGeneralLoot(level, group, data, assigned, cycle); + if (refreshed || personalInserted) data.updateChest(group.anchor, cycle, assigned); + } + + public static boolean personalRoll(RandomSource random, int ordinal) { + if (ordinal <= 0) return false; + if (ordinal == 1) return random.nextFloat() < 0.75F; + if (ordinal == 2) return random.nextFloat() < 0.90F; + if (ordinal == 3) return true; + return random.nextFloat() < 0.65F; + } + + private static void rollGeneralLoot(ServerLevel level, ChestGroup group, BackroomData data, + List assigned, long cycle) { + RandomSource random = RandomSource.create(level.getSeed() ^ group.anchor ^ cycle * 0x9E3779B97F4A7C15L); + int roll = random.nextInt(100); + if (roll < 55) return; + if (roll < 74) { + insert(group, random, Items.DIRT, 4, 20); + if (random.nextBoolean()) insert(group, random, Items.OAK_PLANKS, 2, 10); + return; + } + if (roll < 90) { + insert(group, random, Items.COBBLESTONE, 6, 24); + insert(group, random, random.nextBoolean() ? Items.TORCH : Items.BREAD, 2, 7); + if (random.nextInt(3) == 0) group.insert(new ItemStack(Items.WOODEN_PICKAXE)); + return; + } + if (roll < 97) { + data.takeShared(group.anchor, level.getGameTime()).ifPresent(loss -> { + if (group.insertLoss(loss.stack())) assigned.add(loss.id()); + else data.capture(loss.stack(), null, "chest_full", level.getGameTime()); + }); + return; + } + if (roll < 99) { + group.insert(new ItemStack(Items.IRON_PICKAXE)); + insert(group, random, Items.ENDER_PEARL, 1, 2); + } else { + group.insert(new ItemStack(Items.DIAMOND)); + } + } + + private static void insert(ChestGroup group, RandomSource random, Item item, int minimum, int maximum) { + group.insert(new ItemStack(item, minimum + random.nextInt(maximum - minimum + 1))); + } + + private static List markers(ChestGroup group) { + ArrayList result = new ArrayList<>(); + for (ChestBlockEntity chest : group.chests) for (int slot = 0; slot < chest.getContainerSize(); slot++) { + UUID marker = chest.getItem(slot).get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT); + if (marker != null) result.add(marker); + } + return result; + } + + private static ChestGroup group(ServerLevel level, ChestBlockEntity opened) { + BlockPos pos = opened.getBlockPos(); + BlockState state = opened.getBlockState(); + if (!(state.getBlock() instanceof ChestBlock) || state.getValue(ChestBlock.TYPE) == ChestType.SINGLE) { + return new ChestGroup(pos.asLong(), List.of(opened)); + } + BlockPos connectedPos = ChestBlock.getConnectedBlockPos(pos, state); + if (!(level.getBlockEntity(connectedPos) instanceof ChestBlockEntity connected)) { + return new ChestGroup(pos.asLong(), List.of(opened)); + } + long anchor = Math.min(pos.asLong(), connectedPos.asLong()); + return pos.asLong() == anchor + ? new ChestGroup(anchor, List.of(opened, connected)) + : new ChestGroup(anchor, List.of(connected, opened)); + } + + private record ChestGroup(long anchor, List chests) { + private void clear() { + chests.forEach(chest -> { chest.clearContent(); chest.setChanged(); }); + } + + private boolean insert(ItemStack offered) { + if (offered == null || offered.isEmpty()) return false; + for (ChestBlockEntity chest : chests) if (insertInto(chest, offered)) return true; + return false; + } + + /** Keeps every real loss in the physical anchor chest tracked by the persisted state. */ + private boolean insertLoss(ItemStack offered) { + return offered != null && !offered.isEmpty() && insertInto(chests.getFirst(), offered); + } + + private static boolean insertInto(ChestBlockEntity chest, ItemStack offered) { + for (int slot = 0; slot < chest.getContainerSize(); slot++) { + if (!chest.getItem(slot).isEmpty()) continue; + chest.setItem(slot, offered.copy()); + chest.setChanged(); + return true; + } + return false; + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java new file mode 100644 index 0000000..389c21a --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomData.java @@ -0,0 +1,316 @@ +package fr.koka99cab.sanctuary26.sanctuary.backroom; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Persistent, bounded state for Backroom entries and the real-item loss pool. */ +public final class BackroomData extends SavedData { + public static final String SAVE_KEY = "backroom"; + public static final int DATA_VERSION = 1; + public static final int MAX_PENDING_ENTRIES = 256; + public static final int MAX_ENTRY_SLOTS = 64; + public static final int MAX_LOST_ITEMS = 16_384; + public static final int MAX_CHEST_STATES = 65_536; + public static final int MAX_PLAYER_STATES = 4_096; + public static final int MAX_VISITED_CHESTS = 128; + public static final int MAX_RECENT_ARRIVALS = 8; + public static final long PERSONAL_RESERVATION_TICKS = 24_000L; + public static final String LOST_POOL = "pool"; + public static final String LOST_CHEST = "chest"; + + public static final Codec ENTRY_SLOT_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("location").forGetter(EntrySlot::location), + Codec.INT.fieldOf("index").forGetter(EntrySlot::index), + ItemStack.CODEC.fieldOf("item").forGetter(EntrySlot::item) + ).apply(instance, EntrySlot::new)); + private static final Codec PENDING_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("player").forGetter(PendingEntry::player), + Codec.STRING.fieldOf("transaction").forGetter(PendingEntry::transaction), + ENTRY_SLOT_CODEC.listOf(0, MAX_ENTRY_SLOTS).fieldOf("slots").forGetter(PendingEntry::slots) + ).apply(instance, PendingEntry::new)); + private static final Codec LOST_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("id").forGetter(LostItem::id), + Codec.STRING.optionalFieldOf("owner", "").forGetter(LostItem::owner), + Codec.STRING.fieldOf("cause").forGetter(LostItem::cause), + Codec.LONG.fieldOf("lost_at").forGetter(LostItem::lostAt), + ItemStack.CODEC.fieldOf("item").forGetter(LostItem::item), + Codec.STRING.optionalFieldOf("state", LOST_POOL).forGetter(LostItem::state), + Codec.LONG.optionalFieldOf("chest", Long.MIN_VALUE).forGetter(LostItem::chest) + ).apply(instance, LostItem::new)); + private static final Codec CHEST_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.LONG.fieldOf("pos").forGetter(ChestState::pos), + Codec.LONG.fieldOf("cycle").forGetter(ChestState::cycle), + Codec.STRING.listOf(0, 54).optionalFieldOf("assigned", List.of()).forGetter(ChestState::assigned) + ).apply(instance, ChestState::new)); + private static final Codec PLAYER_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("player").forGetter(PlayerState::player), + Codec.INT.optionalFieldOf("personal_attempts", 0).forGetter(PlayerState::personalAttempts), + Codec.LONG.listOf(0, MAX_VISITED_CHESTS).optionalFieldOf("visited_chests", List.of()).forGetter(PlayerState::visitedChests), + Codec.LONG.listOf(0, MAX_RECENT_ARRIVALS).optionalFieldOf("recent_arrivals", List.of()).forGetter(PlayerState::recentArrivals) + ).apply(instance, PlayerState::new)); + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion), + PENDING_CODEC.listOf(0, MAX_PENDING_ENTRIES).optionalFieldOf("pending_entries", List.of()).forGetter(data -> data.pendingEntries), + LOST_CODEC.listOf(0, MAX_LOST_ITEMS).optionalFieldOf("lost_items", List.of()).forGetter(data -> data.lostItems), + CHEST_CODEC.listOf(0, MAX_CHEST_STATES).optionalFieldOf("chests", List.of()).forGetter(data -> data.chests), + PLAYER_CODEC.listOf(0, MAX_PLAYER_STATES).optionalFieldOf("players", List.of()).forGetter(data -> data.players) + ).apply(instance, BackroomData::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + SanctuaryMod.id(SAVE_KEY), BackroomData::new, CODEC, null); + + private int dataVersion; + private final List pendingEntries; + private final List lostItems; + private final List chests; + private final Map chestIndices; + private final List players; + + public BackroomData() { + this(DATA_VERSION, List.of(), List.of(), List.of(), List.of()); + } + + private BackroomData(int dataVersion, List pendingEntries, List lostItems, + List chests, List players) { + this.dataVersion = Math.max(DATA_VERSION, dataVersion); + this.pendingEntries = bounded(pendingEntries, MAX_PENDING_ENTRIES); + this.lostItems = bounded(lostItems, MAX_LOST_ITEMS); + this.chests = bounded(chests, MAX_CHEST_STATES); + this.chestIndices = new HashMap<>(); + for (int index = 0; index < this.chests.size(); index++) chestIndices.put(this.chests.get(index).pos, index); + this.players = bounded(players, MAX_PLAYER_STATES); + } + + public static BackroomData get(MinecraftServer server) { + ServerLevel overworld = server.getLevel(Level.OVERWORLD); + if (overworld == null) throw new IllegalStateException("Overworld unavailable for Backroom data"); + BackroomData data = overworld.getDataStorage().computeIfAbsent(TYPE); + if (data.dataVersion < DATA_VERSION) { + data.dataVersion = DATA_VERSION; + data.setDirty(); + } + return data; + } + + public Optional pendingEntry(UUID player) { + String id = player.toString(); + return pendingEntries.stream().filter(entry -> entry.player.equals(id)).findFirst(); + } + + public Optional prepareEntry(UUID player, List slots) { + pendingEntries.removeIf(entry -> entry.player.equals(player.toString())); + if (pendingEntries.size() >= MAX_PENDING_ENTRIES) return Optional.empty(); + PendingEntry entry = new PendingEntry(player.toString(), UUID.randomUUID().toString(), copySlots(slots)); + pendingEntries.add(entry); + setDirty(); + return Optional.of(entry); + } + + public void finishEntry(UUID player) { + if (pendingEntries.removeIf(entry -> entry.player.equals(player.toString()))) setDirty(); + } + + /** Adds a new loss or returns a previously materialized loss to the pool. */ + public Optional capture(ItemStack source, UUID owner, String cause, long gameTime) { + if (source == null || source.isEmpty()) return Optional.empty(); + ItemStack clean = source.copy(); + UUID marker = clean.get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT); + clean.remove(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT); + if (marker != null) { + int existing = indexOfLoss(marker); + if (existing >= 0) { + LostItem previous = lostItems.get(existing); + lostItems.set(existing, new LostItem(previous.id, owner == null ? previous.owner : owner.toString(), + cause, gameTime, clean, LOST_POOL, Long.MIN_VALUE)); + setDirty(); + return Optional.of(marker); + } + } + if (lostItems.size() >= MAX_LOST_ITEMS) return Optional.empty(); + UUID id = marker == null ? UUID.randomUUID() : marker; + lostItems.add(new LostItem(id.toString(), owner == null ? "" : owner.toString(), cause, + gameTime, clean, LOST_POOL, Long.MIN_VALUE)); + setDirty(); + return Optional.of(id); + } + + public Optional takePersonal(UUID owner, long chestPos) { + String player = owner.toString(); + return lostItems.stream() + .filter(loss -> loss.state.equals(LOST_POOL) && loss.owner.equals(player)) + .max(Comparator.comparingLong(LostItem::lostAt)) + .flatMap(loss -> materialize(loss, chestPos)); + } + + public Optional takeShared(long chestPos, long gameTime) { + return lostItems.stream() + .filter(loss -> loss.state.equals(LOST_POOL) + && sharedEligible(loss.owner, loss.lostAt, gameTime)) + .min(Comparator.comparingLong(LostItem::lostAt)) + .flatMap(loss -> materialize(loss, chestPos)); + } + + static boolean sharedEligible(String owner, long lostAt, long gameTime) { + return owner == null || owner.isEmpty() + || gameTime >= lostAt && gameTime - lostAt >= PERSONAL_RESERVATION_TICKS; + } + + private Optional materialize(LostItem loss, long chestPos) { + int index = lostItems.indexOf(loss); + if (index < 0) return Optional.empty(); + ItemStack marked = loss.item.copy(); + UUID id = loss.uuid(); + marked.set(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT, id); + lostItems.set(index, new LostItem(loss.id, loss.owner, loss.cause, loss.lostAt, + loss.item.copy(), LOST_CHEST, chestPos)); + setDirty(); + return Optional.of(new MaterializedLoss(id, marked)); + } + + /** Reconciles real items allocated during the chest's previous loot cycle. */ + public void reconcileChest(long chestPos, List markersStillPresent) { + List present = markersStillPresent.stream().map(UUID::toString).toList(); + boolean changed = false; + for (int index = lostItems.size() - 1; index >= 0; index--) { + LostItem loss = lostItems.get(index); + if (!loss.state.equals(LOST_CHEST) || loss.chest != chestPos) continue; + if (present.contains(loss.id)) { + lostItems.set(index, new LostItem(loss.id, loss.owner, loss.cause, loss.lostAt, + loss.item.copy(), LOST_POOL, Long.MIN_VALUE)); + } else { + lostItems.remove(index); + } + changed = true; + } + if (changed) setDirty(); + } + + /** Settles a marked item that successfully left the Backroom. */ + public void settle(UUID lossId) { + if (lossId != null && lostItems.removeIf(loss -> loss.id.equals(lossId.toString()))) setDirty(); + } + + public Optional chest(long pos) { + Integer index = chestIndices.get(pos); + return index == null ? Optional.empty() : Optional.of(chests.get(index)); + } + + public void updateChest(long pos, long cycle, List assigned) { + ChestState replacement = new ChestState(pos, cycle, assigned.stream().limit(54).map(UUID::toString).toList()); + Integer existing = chestIndices.get(pos); + if (existing != null) { + chests.set(existing, replacement); + } else { + if (chests.size() >= MAX_CHEST_STATES) { + chests.removeFirst(); + chestIndices.clear(); + for (int index = 0; index < chests.size(); index++) chestIndices.put(chests.get(index).pos, index); + } + chestIndices.put(pos, chests.size()); + chests.add(replacement); + } + setDirty(); + } + + /** Marks a chest and returns whether it is a new personal-recovery attempt. */ + public ChestVisit visitChest(UUID player, long chestPos) { + PlayerState state = playerState(player); + ArrayList visited = new ArrayList<>(state.visitedChests); + boolean firstVisit = !visited.contains(chestPos); + if (firstVisit) { + if (visited.size() >= MAX_VISITED_CHESTS) visited.removeFirst(); + visited.add(chestPos); + state = replacePlayer(state, state.personalAttempts + 1, visited, state.recentArrivals); + } + return new ChestVisit(firstVisit, state.personalAttempts); + } + + public void beginExpedition(UUID player, long packedArrival) { + PlayerState state = playerState(player); + ArrayList arrivals = new ArrayList<>(state.recentArrivals); + arrivals.remove(packedArrival); + arrivals.addFirst(packedArrival); + while (arrivals.size() > MAX_RECENT_ARRIVALS) arrivals.removeLast(); + replacePlayer(state, 0, List.of(), arrivals); + } + + public List recentArrivals(UUID player) { + return List.copyOf(playerState(player).recentArrivals); + } + + public int lostItemCount() { return lostItems.size(); } + public long pooledItemCount() { return lostItems.stream().filter(loss -> loss.state.equals(LOST_POOL)).count(); } + public long pooledItemCount(UUID owner) { + String id = owner.toString(); + return lostItems.stream().filter(loss -> loss.state.equals(LOST_POOL) && loss.owner.equals(id)).count(); + } + public int trackedChestCount() { return chests.size(); } + + private PlayerState playerState(UUID player) { + String id = player.toString(); + return players.stream().filter(state -> state.player.equals(id)).findFirst().orElseGet(() -> { + if (players.size() >= MAX_PLAYER_STATES) players.removeFirst(); + PlayerState state = new PlayerState(id, 0, List.of(), List.of()); + players.add(state); + setDirty(); + return state; + }); + } + + private PlayerState replacePlayer(PlayerState old, int attempts, List visited, List arrivals) { + PlayerState replacement = new PlayerState(old.player, Math.max(0, attempts), + bounded(visited, MAX_VISITED_CHESTS), bounded(arrivals, MAX_RECENT_ARRIVALS)); + players.set(players.indexOf(old), replacement); + setDirty(); + return replacement; + } + + private int indexOfLoss(UUID id) { + String value = id.toString(); + for (int index = 0; index < lostItems.size(); index++) if (lostItems.get(index).id.equals(value)) return index; + return -1; + } + + private static List copySlots(List slots) { + return slots.stream().filter(slot -> slot != null && !slot.item.isEmpty()).limit(MAX_ENTRY_SLOTS) + .map(slot -> new EntrySlot(slot.location, slot.index, slot.item.copy())).toList(); + } + + private static ArrayList bounded(List source, int maximum) { + List safe = source == null ? List.of() : source; + int start = Math.max(0, safe.size() - maximum); + return new ArrayList<>(safe.subList(start, safe.size())); + } + + public record EntrySlot(String location, int index, ItemStack item) { + public EntrySlot { item = item == null ? ItemStack.EMPTY : item.copy(); } + } + public record PendingEntry(String player, String transaction, List slots) { + public UUID transactionId() { return UUID.fromString(transaction); } + } + public record LostItem(String id, String owner, String cause, long lostAt, ItemStack item, + String state, long chest) { + public UUID uuid() { return UUID.fromString(id); } + } + public record ChestState(long pos, long cycle, List assigned) { + public List assignedIds() { return assigned.stream().map(UUID::fromString).toList(); } + } + private record PlayerState(String player, int personalAttempts, List visitedChests, + List recentArrivals) {} + public record MaterializedLoss(UUID id, ItemStack stack) {} + public record ChestVisit(boolean firstVisit, int ordinal) {} +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java new file mode 100644 index 0000000..9e3198a --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomInventory.java @@ -0,0 +1,108 @@ +package fr.koka99cab.sanctuary26.sanctuary.backroom; + +import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity; +import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryPlayerProgress; +import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; + +/** Exact inventory boundary used by Backroom entry, death and exit. */ +public final class BackroomInventory { + private static final String INVENTORY = "inventory"; + private static final String OVERFLOW = "overflow"; + private static final String EGG = "companion_egg"; + private static final String CAPE = "cape"; + + private BackroomInventory() {} + + public static List snapshot(ServerPlayer player) { + ArrayList result = new ArrayList<>(); + Inventory inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getContainerSize(); slot++) { + add(result, INVENTORY, slot, inventory.getItem(slot)); + } + for (int slot = 0; slot < SanctuaryPlayerProgress.OVERFLOW_SLOT_COUNT; slot++) { + add(result, OVERFLOW, slot, SanctuaryInventoryCapacity.overflowItem(player, slot)); + } + add(result, EGG, 0, SanctuaryInventoryCapacity.companionEgg(player)); + add(result, CAPE, 0, SanctuaryInventoryCapacity.equippedCape(player)); + return result; + } + + public static List parcel(List slots) { + return slots.stream().map(BackroomData.EntrySlot::item).filter(stack -> !stack.isEmpty()) + .map(ItemStack::copy).toList(); + } + + /** Clears only exact snapshot values, so recovery never destroys a later replacement item. */ + public static void clearMatching(ServerPlayer player, List slots) { + for (BackroomData.EntrySlot slot : slots) { + ItemStack current = get(player, slot); + if (ItemStack.matches(current, slot.item())) set(player, slot, ItemStack.EMPTY); + } + player.getInventory().setChanged(); + SanctuaryInventoryCapacity.markOverflowChanged(player); + } + + public static int captureAll(ServerPlayer player, String cause) { + BackroomData data = BackroomData.get(player.level().getServer()); + long now = player.level().getGameTime(); + int captured = 0; + for (BackroomData.EntrySlot slot : snapshot(player)) { + if (data.capture(slot.item(), player.getUUID(), cause, now).isEmpty()) continue; + set(player, slot, ItemStack.EMPTY); + captured++; + } + player.getInventory().setChanged(); + SanctuaryInventoryCapacity.markOverflowChanged(player); + return captured; + } + + /** Removes registry markers only when their real item successfully crosses a bed exit. */ + public static int settleCarriedLosses(ServerPlayer player) { + BackroomData data = BackroomData.get(player.level().getServer()); + int settled = 0; + for (BackroomData.EntrySlot slot : snapshot(player)) { + ItemStack item = get(player, slot); + UUID id = item.get(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT); + if (id == null) continue; + item.remove(SanctuaryRegistries.BACKROOM_LOSS_COMPONENT); + data.settle(id); + set(player, slot, item); + settled++; + } + return settled; + } + + private static void add(List target, String location, int index, ItemStack stack) { + if (stack != null && !stack.isEmpty()) target.add(new BackroomData.EntrySlot(location, index, stack)); + } + + private static ItemStack get(ServerPlayer player, BackroomData.EntrySlot slot) { + return switch (slot.location()) { + case INVENTORY -> slot.index() >= 0 && slot.index() < player.getInventory().getContainerSize() + ? player.getInventory().getItem(slot.index()) : ItemStack.EMPTY; + case OVERFLOW -> SanctuaryInventoryCapacity.overflowItem(player, slot.index()); + case EGG -> SanctuaryInventoryCapacity.companionEgg(player); + case CAPE -> SanctuaryInventoryCapacity.equippedCape(player); + default -> ItemStack.EMPTY; + }; + } + + private static void set(ServerPlayer player, BackroomData.EntrySlot slot, ItemStack stack) { + switch (slot.location()) { + case INVENTORY -> { + if (slot.index() >= 0 && slot.index() < player.getInventory().getContainerSize()) + player.getInventory().setItem(slot.index(), stack); + } + case OVERFLOW -> SanctuaryInventoryCapacity.setOverflowItem(player, slot.index(), stack); + case EGG -> SanctuaryInventoryCapacity.setCompanionEgg(player, stack); + case CAPE -> SanctuaryInventoryCapacity.setEquippedCape(player, stack); + default -> { } + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java new file mode 100644 index 0000000..9e2b256 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomService.java @@ -0,0 +1,240 @@ +package fr.koka99cab.sanctuary26.sanctuary.backroom; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.shop.ShopData; +import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.phys.Vec3; + +/** Bed gateway and crash-safe Mailbox boundary for Backroom expeditions. */ +public final class BackroomService { + public static final int ARRIVAL_RADIUS = 2_000; + private static final int ARRIVAL_ATTEMPTS = 48; + private static final Set AUTHORIZED_TRANSITIONS = new HashSet<>(); + private static boolean initialized; + + private BackroomService() {} + + public static void initialize() { + if (initialized) return; + initialized = true; + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> reconcile(handler.player)); + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + dispatcher.register(Commands.literal("backroom") + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .executes(context -> status(context.getSource())) + .then(Commands.literal("status").executes(context -> status(context.getSource()))) + .then(Commands.literal("inspect") + .then(Commands.argument("player", EntityArgument.player()) + .executes(context -> inspect(context.getSource(), EntityArgument.getPlayer(context, "player")))))); + }); + SanctuaryMod.LOGGER.info("[{}] Backroom bed gateway ready (arrival radius {} blocks).", + SanctuaryMod.DISPLAY_NAME, ARRIVAL_RADIUS); + } + + /** Returns true when the bed interaction belongs to the Backroom feature. */ + public static boolean useBed(ServerPlayer player) { + if (isBackroom(player.level())) { + exit(player); + return true; + } + if (!player.isSecondaryUseActive()) return false; + enter(player); + return true; + } + + public static boolean isBackroom(ServerLevel level) { + return level != null && level.dimension().equals(SanctuaryDimensions.BACKROOM); + } + + public static boolean enter(ServerPlayer player) { + ServerLevel destination = player.level().getServer().getLevel(SanctuaryDimensions.BACKROOM); + if (destination == null) { + notify(player, "message.sanctuary.backroom.unavailable"); + return false; + } + if (!reconcile(player)) { + notify(player, "message.sanctuary.backroom.pending"); + return false; + } + + List slots = BackroomInventory.snapshot(player); + ShopData.DeliveryDestination mailbox = null; + if (!slots.isEmpty()) { + mailbox = ShopData.get(player.level().getServer()).loadedDeliveryBox( + player.level().getServer(), player.getUUID(), level -> !isBackroom(level)).orElse(null); + if (mailbox == null) { + notify(player, "message.sanctuary.backroom.mailbox_missing"); + return false; + } + } + + BackroomData data = BackroomData.get(player.level().getServer()); + BackroomData.PendingEntry pending = data.prepareEntry(player.getUUID(), slots).orElse(null); + if (pending == null) { + notify(player, "message.sanctuary.backroom.registry_busy"); + return false; + } + if (mailbox != null && !mailbox.box().insertTransaction(pending.transactionId(), BackroomInventory.parcel(slots))) { + data.finishEntry(player.getUUID()); + notify(player, "message.sanctuary.backroom.mailbox_full"); + return false; + } + BackroomInventory.clearMatching(player, slots); + data.finishEntry(player.getUUID()); + + BlockPos arrival = chooseArrival(destination, player.getUUID(), data); + data.beginExpedition(player.getUUID(), arrival.asLong()); + TeleportTransition transition = new TeleportTransition(destination, Vec3.atBottomCenterOf(arrival), + Vec3.ZERO, player.getYRot(), player.getXRot(), + TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET)); + ServerPlayer teleported; + AUTHORIZED_TRANSITIONS.add(player.getUUID()); + try { + teleported = player.teleport(transition); + } finally { + AUTHORIZED_TRANSITIONS.remove(player.getUUID()); + } + if (teleported == null) { + SanctuaryMod.LOGGER.error("Backroom teleport failed after Mailbox transaction {} for {}", + pending.transaction(), player.getGameProfile().name()); + return false; + } + notify(player, "message.sanctuary.backroom.entered"); + return true; + } + + public static boolean exit(ServerPlayer player) { + if (!isBackroom(player.level())) return false; + TeleportTransition transition = player.findRespawnPositionAndUseSpawnBlock(false, + TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET)); + if (isBackroom(transition.newLevel())) { + transition = TeleportTransition.createDefault(player, + TeleportTransition.PLAY_PORTAL_SOUND.then(TeleportTransition.PLACE_PORTAL_TICKET)); + } + ServerPlayer teleported; + AUTHORIZED_TRANSITIONS.add(player.getUUID()); + try { + teleported = player.teleport(transition); + } finally { + AUTHORIZED_TRANSITIONS.remove(player.getUUID()); + } + if (teleported == null) return false; + int recovered = BackroomInventory.settleCarriedLosses(teleported); + notify(teleported, recovered == 0 + ? "message.sanctuary.backroom.exited" + : "message.sanctuary.backroom.exited_with_losses"); + return true; + } + + public static boolean isAuthorizedTransition(UUID player) { + return player != null && AUTHORIZED_TRANSITIONS.contains(player); + } + + /** Resolves a crash between Mailbox insertion and inventory clearing. */ + public static boolean reconcile(ServerPlayer player) { + BackroomData data = BackroomData.get(player.level().getServer()); + BackroomData.PendingEntry pending = data.pendingEntry(player.getUUID()).orElse(null); + if (pending == null) return true; + ShopData.DeliveryDestination mailbox = ShopData.get(player.level().getServer()) + .loadedDeliveryBox(player.level().getServer(), player.getUUID(), level -> !isBackroom(level)).orElse(null); + if (mailbox == null) return false; + if (mailbox.box().hasProcessedTransaction(pending.transactionId())) { + BackroomInventory.clearMatching(player, pending.slots()); + } + data.finishEntry(player.getUUID()); + return true; + } + + private static BlockPos chooseArrival(ServerLevel level, UUID player, BackroomData data) { + RandomSource random = level.getRandom(); + List recent = data.recentArrivals(player); + BlockPos best = null; + for (int attempt = 0; attempt < ARRIVAL_ATTEMPTS; attempt++) { + double angle = random.nextDouble() * Math.PI * 2.0D; + double radius = Math.sqrt(random.nextDouble()) * ARRIVAL_RADIUS; + int x = Mth.floor(Math.cos(angle) * radius); + int z = Mth.floor(Math.sin(angle) * radius); + int y = Mth.clamp(level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), + level.getMinY() + 2, level.getMaxY() - 3); + BlockPos candidate = new BlockPos(x, y, z); + best = candidate; + if (farFromRecent(candidate, recent) && safe(level, candidate)) return candidate; + } + BlockPos fallback = findSafeNear(level, best == null ? BlockPos.ZERO : best, 32); + if (fallback != null) return fallback; + fallback = findSafeNear(level, BlockPos.ZERO, 48); + if (fallback != null) return fallback; + int originY = Mth.clamp(level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, 0, 0), + level.getMinY() + 2, level.getMaxY() - 3); + return new BlockPos(0, originY, 0); + } + + private static boolean farFromRecent(BlockPos candidate, List recent) { + for (long packed : recent) { + BlockPos old = BlockPos.of(packed); + long dx = old.getX() - candidate.getX(), dz = old.getZ() - candidate.getZ(); + if (dx * dx + dz * dz < 128L * 128L) return false; + } + return true; + } + + private static boolean safe(ServerLevel level, BlockPos feet) { + return level.getBlockState(feet).isAir() && level.getBlockState(feet.above()).isAir() + && level.getBlockState(feet.below()).isCollisionShapeFullBlock(level, feet.below()); + } + + private static BlockPos findSafeNear(ServerLevel level, BlockPos center, int radius) { + for (int ring = 0; ring <= radius; ring += 2) { + for (int dx = -ring; dx <= ring; dx += 2) for (int dz = -ring; dz <= ring; dz += 2) { + if (ring > 0 && Math.abs(dx) != ring && Math.abs(dz) != ring) continue; + int x = center.getX() + dx, z = center.getZ() + dz; + if ((long) x * x + (long) z * z > (long) ARRIVAL_RADIUS * ARRIVAL_RADIUS) continue; + int y = Mth.clamp(level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z), + level.getMinY() + 2, level.getMaxY() - 3); + BlockPos candidate = new BlockPos(x, y, z); + if (safe(level, candidate)) return candidate; + } + } + return null; + } + + private static void notify(ServerPlayer player, String translationKey) { + player.sendOverlayMessage(Component.translatable(translationKey)); + } + + private static int status(CommandSourceStack source) { + BackroomData data = BackroomData.get(source.getServer()); + source.sendSuccess(() -> Component.literal("Backroom: " + data.lostItemCount() + " lost stacks (" + + data.pooledItemCount() + " pooled), " + data.trackedChestCount() + " tracked chest positions; data v" + + BackroomData.DATA_VERSION + "."), false); + return data.lostItemCount(); + } + + private static int inspect(CommandSourceStack source, ServerPlayer player) { + BackroomData data = BackroomData.get(source.getServer()); + boolean pending = data.pendingEntry(player.getUUID()).isPresent(); + source.sendSuccess(() -> Component.literal("Backroom player " + player.getGameProfile().name() + ": " + + data.pooledItemCount(player.getUUID()) + " personal pooled stacks, entry transaction " + + (pending ? "pending" : "clear") + ", " + data.recentArrivals(player.getUUID()).size() + + " remembered arrivals."), false); + return pending ? 0 : 1; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/BedBlockMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/BedBlockMixin.java new file mode 100644 index 0000000..0437d6e --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/BedBlockMixin.java @@ -0,0 +1,26 @@ +package fr.koka99cab.sanctuary26.sanctuary.mixin; + +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BedBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(BedBlock.class) +abstract class BedBlockMixin { + @Inject(method = "useWithoutItem", at = @At("HEAD"), cancellable = true) + private void sanctuary$useBackroomBed(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit, CallbackInfoReturnable callback) { + if (player instanceof ServerPlayer serverPlayer && BackroomService.useBed(serverPlayer)) { + callback.setReturnValue(InteractionResult.SUCCESS_SERVER); + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ChestBlockEntityMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ChestBlockEntityMixin.java new file mode 100644 index 0000000..27e47cb --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ChestBlockEntityMixin.java @@ -0,0 +1,20 @@ +package fr.koka99cab.sanctuary26.sanctuary.mixin; + +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomChestService; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ContainerUser; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ChestBlockEntity.class) +abstract class ChestBlockEntityMixin { + @Inject(method = "startOpen", at = @At("HEAD")) + private void sanctuary$refreshBackroomChest(ContainerUser user, CallbackInfo callback) { + if (user.getLivingEntity() instanceof ServerPlayer player) { + BackroomChestService.open((ChestBlockEntity) (Object) this, player); + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ItemEntityMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ItemEntityMixin.java new file mode 100644 index 0000000..0e6a22e --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ItemEntityMixin.java @@ -0,0 +1,35 @@ +package fr.koka99cab.sanctuary26.sanctuary.mixin; + +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomData; +import java.util.UUID; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.ItemEntity; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ItemEntity.class) +abstract class ItemEntityMixin { + @Shadow private @Nullable UUID target; + + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void sanctuary$captureLostItem(CallbackInfo callback) { + ItemEntity item = (ItemEntity) (Object) this; + if (!(item.level() instanceof ServerLevel level) || item.getItem().isEmpty()) return; + boolean voidLoss = item.getY() < level.getMinY() - 64.0D; + boolean despawn = item.getAge() >= 5_999; + if (!voidLoss && !despawn) return; + UUID owner = target; + Entity thrower = item.getOwner(); + if (owner == null && thrower instanceof ServerPlayer player) owner = player.getUUID(); + if (BackroomData.get(level.getServer()).capture(item.getItem(), owner, + voidLoss ? "void" : "despawn", level.getGameTime()).isEmpty()) return; + item.discard(); + callback.cancel(); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ServerPlayerBackroomMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ServerPlayerBackroomMixin.java new file mode 100644 index 0000000..15576de --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/ServerPlayerBackroomMixin.java @@ -0,0 +1,44 @@ +package fr.koka99cab.sanctuary26.sanctuary.mixin; + +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomInventory; +import fr.koka99cab.sanctuary26.sanctuary.backroom.BackroomService; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageTypes; +import net.minecraft.world.level.portal.TeleportTransition; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ServerPlayer.class) +abstract class ServerPlayerBackroomMixin { + @Inject(method = "teleport", at = @At("HEAD"), cancellable = true) + private void sanctuary$enforceBackroomExtraction(TeleportTransition transition, + CallbackInfoReturnable callback) { + ServerPlayer player = (ServerPlayer) (Object) this; + boolean leaving = BackroomService.isBackroom(player.level()) + && !BackroomService.isBackroom(transition.newLevel()); + boolean entering = !BackroomService.isBackroom(player.level()) + && BackroomService.isBackroom(transition.newLevel()); + if ((!leaving && !entering) || BackroomService.isAuthorizedTransition(player.getUUID())) return; + if (entering) { + player.sendOverlayMessage(Component.translatable("message.sanctuary.backroom.bed_entry_only")); + callback.setReturnValue(null); + return; + } + BackroomInventory.captureAll(player, "backroom_forced_exit"); + } + + @Inject(method = "die", at = @At("HEAD")) + private void sanctuary$captureBackroomDeath(DamageSource source, CallbackInfo callback) { + ServerPlayer player = (ServerPlayer) (Object) this; + if (BackroomService.isBackroom(player.level())) { + BackroomInventory.captureAll(player, "backroom_death"); + } else if (source.is(DamageTypes.FELL_OUT_OF_WORLD)) { + BackroomInventory.captureAll(player, "void_death"); + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/registry/SanctuaryRegistries.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/registry/SanctuaryRegistries.java index 6114958..8a52f1d 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/registry/SanctuaryRegistries.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/registry/SanctuaryRegistries.java @@ -9,6 +9,9 @@ import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestSignItem; import fr.koka99cab.sanctuary26.sanctuary.shop.QuestSignItem; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapes; import java.util.function.Function; +import java.util.UUID; +import net.minecraft.core.UUIDUtil; +import net.minecraft.core.component.DataComponentType; import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents; import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType; import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; @@ -37,6 +40,16 @@ import net.minecraft.world.level.material.MapColor; * Feature registries must be called from here to keep initialization ordering explicit. */ public final class SanctuaryRegistries { + public static final Identifier BACKROOM_LOSS_ID = SanctuaryMod.id("backroom_loss_id"); + public static final DataComponentType BACKROOM_LOSS_COMPONENT = Registry.register( + BuiltInRegistries.DATA_COMPONENT_TYPE, + BACKROOM_LOSS_ID, + DataComponentType.builder() + .persistent(UUIDUtil.CODEC) + .networkSynchronized(UUIDUtil.STREAM_CODEC) + .cacheEncoding() + .build() + ); public static final Identifier DELIVERY_BOX_ID = SanctuaryMod.id("delivery_box"); public static final DeliveryBoxBlock DELIVERY_BOX = registerBlock( DELIVERY_BOX_ID, diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java index fdb8f3e..2ab5db9 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/DeliveryBoxBlockEntity.java @@ -1,6 +1,10 @@ package fr.koka99cab.sanctuary26.sanctuary.shop; import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.UUID; import net.minecraft.core.BlockPos; import net.minecraft.core.NonNullList; @@ -19,7 +23,9 @@ import net.minecraft.world.level.storage.ValueOutput; public final class DeliveryBoxBlockEntity extends BaseContainerBlockEntity { public static final int SIZE = 27; + private static final int MAX_PROCESSED_TRANSACTIONS = 128; private NonNullList items = NonNullList.withSize(SIZE, ItemStack.EMPTY); + private final Set processedTransactions = new LinkedHashSet<>(); private UUID owner; public DeliveryBoxBlockEntity(BlockPos pos, BlockState state) { @@ -68,16 +74,68 @@ public final class DeliveryBoxBlockEntity extends BaseContainerBlockEntity { return remaining.isEmpty(); } + /** + * Atomically inserts a complete Backroom parcel. Replaying the same transaction is a no-op, + * which makes the mailbox side of an entry safe across a server crash. + */ + public boolean insertTransaction(UUID transactionId, List deliveries) { + if (transactionId == null || deliveries == null) return false; + if (processedTransactions.contains(transactionId)) return true; + NonNullList simulated = copyItems(items); + for (ItemStack delivery : deliveries) { + if (delivery == null || delivery.isEmpty()) continue; + if (!insertInto(simulated, delivery.copy())) return false; + } + items = simulated; + processedTransactions.add(transactionId); + while (processedTransactions.size() > MAX_PROCESSED_TRANSACTIONS) { + processedTransactions.remove(processedTransactions.iterator().next()); + } + setChanged(); + return true; + } + + public boolean hasProcessedTransaction(UUID transactionId) { + return transactionId != null && processedTransactions.contains(transactionId); + } + + private static NonNullList copyItems(List source) { + NonNullList copy = NonNullList.withSize(SIZE, ItemStack.EMPTY); + for (int slot = 0; slot < Math.min(SIZE, source.size()); slot++) copy.set(slot, source.get(slot).copy()); + return copy; + } + + private static boolean insertInto(NonNullList target, ItemStack offered) { + for (int slot = 0; slot < target.size() && !offered.isEmpty(); slot++) { + ItemStack existing = target.get(slot); + if (existing.isEmpty() || !ItemStack.isSameItemSameComponents(existing, offered)) continue; + int moved = Math.min(offered.getCount(), existing.getMaxStackSize() - existing.getCount()); + if (moved > 0) { existing.grow(moved); offered.shrink(moved); } + } + for (int slot = 0; slot < target.size() && !offered.isEmpty(); slot++) { + if (!target.get(slot).isEmpty()) continue; + int moved = Math.min(offered.getCount(), offered.getMaxStackSize()); + target.set(slot, offered.copyWithCount(moved)); + offered.shrink(moved); + } + return offered.isEmpty(); + } + @Override protected void saveAdditional(ValueOutput output) { super.saveAdditional(output); ContainerHelper.saveAllItems(output, items); if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner); + output.store("BackroomTransactions", UUIDUtil.CODEC.listOf(0, MAX_PROCESSED_TRANSACTIONS), + new ArrayList<>(processedTransactions)); } @Override protected void loadAdditional(ValueInput input) { super.loadAdditional(input); items = NonNullList.withSize(SIZE, ItemStack.EMPTY); ContainerHelper.loadAllItems(input, items); owner = input.read("Owner", UUIDUtil.CODEC).orElse(null); + processedTransactions.clear(); + input.read("BackroomTransactions", UUIDUtil.CODEC.listOf(0, MAX_PROCESSED_TRANSACTIONS)).orElse(List.of()).stream() + .limit(MAX_PROCESSED_TRANSACTIONS).forEach(processedTransactions::add); } @Override public int getContainerSize() { return SIZE; } @Override protected NonNullList getItems() { return items; } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopData.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopData.java index f335b86..6fa949c 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopData.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopData.java @@ -8,6 +8,7 @@ import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Predicate; import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -107,6 +108,11 @@ public final class ShopData extends SavedData { } public Optional loadedDeliveryBox(MinecraftServer server, UUID owner) { + return loadedDeliveryBox(server, owner, level -> true); + } + + public Optional loadedDeliveryBox(MinecraftServer server, UUID owner, + Predicate allowedLevel) { String ownerId = owner.toString(); boolean changed = false; Iterator iterator = deliveryBoxes.iterator(); @@ -117,7 +123,7 @@ public final class ShopData extends SavedData { for (ServerLevel candidate : server.getAllLevels()) { if (dimension(candidate).equals(address.dimension)) { level = candidate; break; } } - if (level == null) continue; + if (level == null || allowedLevel == null || !allowedLevel.test(level)) continue; BlockPos pos = BlockPos.of(address.pos); if (!level.isLoaded(pos)) continue; if (!(level.getBlockEntity(pos) instanceof DeliveryBoxBlockEntity box) || !owner.equals(box.owner())) { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/world/SanctuaryDimensions.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/world/SanctuaryDimensions.java index 3fc1a50..1e12afa 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/world/SanctuaryDimensions.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/world/SanctuaryDimensions.java @@ -26,6 +26,7 @@ public final class SanctuaryDimensions { public static final ResourceKey PAINTED_CANYONS = dimension("painted_canyons"); public static final ResourceKey BREAKER_SEA = dimension("breaker_sea"); public static final ResourceKey LAPUTA = dimension("laputa"); + public static final ResourceKey BACKROOM = dimension("backroom"); public static final ResourceKey MAIN_STEM = stem("main"); public static final ResourceKey OVERWORLD_STEM = stem("overworld"); @@ -37,11 +38,12 @@ public final class SanctuaryDimensions { public static final ResourceKey PAINTED_CANYONS_STEM = stem("painted_canyons"); public static final ResourceKey BREAKER_SEA_STEM = stem("breaker_sea"); public static final ResourceKey LAPUTA_STEM = stem("laputa"); + public static final ResourceKey BACKROOM_STEM = stem("backroom"); public static final Set RESERVED_IDS = Set.of( MAIN.identifier(), OVERWORLD.identifier(), CAVERNS.identifier(), ALPHA.identifier(), SHEEP_LAND.identifier(), SKELETON_WASTE.identifier(), LOST_CITY.identifier(), - PAINTED_CANYONS.identifier(), BREAKER_SEA.identifier(), LAPUTA.identifier() + PAINTED_CANYONS.identifier(), BREAKER_SEA.identifier(), LAPUTA.identifier(), BACKROOM.identifier() ); private SanctuaryDimensions() { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/SanctuaryFeatures.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/SanctuaryFeatures.java index 9b5c059..b9e97b3 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/SanctuaryFeatures.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/SanctuaryFeatures.java @@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.MatterDepthMaterializ import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.AlphaIndevChunkFeature; import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.SanctuaryWaterLakeFeature; import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.SulfurCaveGeologyFeature; +import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomArchitectureFeature; import net.fabricmc.fabric.api.biome.v1.BiomeModifications; import net.fabricmc.fabric.api.biome.v1.BiomeSelectors; import net.minecraft.core.Registry; @@ -22,6 +23,9 @@ import net.minecraft.world.level.levelgen.placement.PlacedFeature; /** Registers the two generator-specific features migrated from Sanctuary 26.1.2. */ public final class SanctuaryFeatures { + public static final Identifier BACKROOM_ARCHITECTURE_ID = SanctuaryMod.id("backroom_architecture"); + public static final Feature BACKROOM_ARCHITECTURE = + new BackroomArchitectureFeature(NoneFeatureConfiguration.CODEC); public static final Identifier ALPHA_INDEV_CHUNK_ID = SanctuaryMod.id("alpha_indev_chunk"); public static final Feature ALPHA_INDEV_CHUNK = new AlphaIndevChunkFeature(NoneFeatureConfiguration.CODEC); @@ -56,6 +60,7 @@ public final class SanctuaryFeatures { } public static void register() { + Registry.register(BuiltInRegistries.FEATURE, BACKROOM_ARCHITECTURE_ID, BACKROOM_ARCHITECTURE); Registry.register(BuiltInRegistries.FEATURE, ALPHA_INDEV_CHUNK_ID, ALPHA_INDEV_CHUNK); Registry.register(BuiltInRegistries.FEATURE, MATTER_DEPTH_MATERIALIZATION_ID, MATTER_DEPTH_MATERIALIZATION); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java new file mode 100644 index 0000000..fa3dc51 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/BackroomArchitectureFeature.java @@ -0,0 +1,243 @@ +package fr.koka99cab.sanctuary26.sanctuary.worldgen.feature; + +import com.mojang.serialization.Codec; +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi; +import java.util.Random; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.BedBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LeavesBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BedPart; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; +import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; + +/** + * Deterministic, chunk-local architecture for the persistent Backroom. + * Macro cells select authored palettes; randomness only varies plans inside a coherent family. + */ +public final class BackroomArchitectureFeature extends Feature { + public static final int PLAYABLE_RADIUS = 2_000; + private static final int CHUNK_SIZE = 16; + private static final int MACRO_CHUNKS = 8; + private static final BlockState CONCRETE_GRAY = Blocks.CONCRETE.pick(DyeColor.GRAY).defaultBlockState(); + private static final BlockState CONCRETE_LIGHT = Blocks.CONCRETE.pick(DyeColor.LIGHT_GRAY).defaultBlockState(); + private static final BlockState CONCRETE_WHITE = Blocks.CONCRETE.pick(DyeColor.WHITE).defaultBlockState(); + private static final BlockState GLASS_CYAN = Blocks.STAINED_GLASS.pick(DyeColor.CYAN).defaultBlockState(); + private static final BlockState CLAY_BROWN = Blocks.DYED_TERRACOTTA.pick(DyeColor.BROWN).defaultBlockState(); + private static final BlockState CLAY_ORANGE = Blocks.DYED_TERRACOTTA.pick(DyeColor.ORANGE).defaultBlockState(); + private static final BlockState LEAVES = Blocks.OAK_LEAVES.defaultBlockState().setValue(LeavesBlock.PERSISTENT, true); + + public BackroomArchitectureFeature(Codec codec) { + super(codec); + } + + @Override + public boolean place(FeaturePlaceContext context) { + WorldGenLevel level = context.level(); + int chunkX = Math.floorDiv(context.origin().getX(), CHUNK_SIZE); + int chunkZ = Math.floorDiv(context.origin().getZ(), CHUNK_SIZE); + int startX = chunkX * CHUNK_SIZE; + int startZ = chunkZ * CHUNK_SIZE; + if (!withinPlayableArea(startX + 8, startZ + 8)) return false; + + long seed = mix(level.getSeed(), chunkX, chunkZ); + Random random = new Random(seed); + Architecture architecture = architecture(level.getSeed(), chunkX, chunkZ); + switch (architecture) { + case CITY -> city(level, startX, startZ, random); + case OFFICES -> offices(level, startX, startZ, random); + case PLAINS -> plains(level, startX, startZ, random); + case AGGREGATES -> aggregates(level, startX, startZ, random); + case ROTATED -> rotated(level, startX, startZ, random); + } + placeLootLandmarks(level, startX, startZ, random, architecture); + placeRareAnomaly(level, startX, startZ, random); + return true; + } + + public static boolean withinPlayableArea(int x, int z) { + return (long) x * x + (long) z * z <= (long) PLAYABLE_RADIUS * PLAYABLE_RADIUS; + } + + public static Architecture architecture(long worldSeed, int chunkX, int chunkZ) { + int macroX = Math.floorDiv(chunkX, MACRO_CHUNKS); + int macroZ = Math.floorDiv(chunkZ, MACRO_CHUNKS); + int index = Math.floorMod((int) mix(worldSeed ^ 0x4f1bbcdcL, macroX, macroZ), Architecture.values().length); + return Architecture.values()[index]; + } + + /** Stable fingerprint used to prevent palette selection drift. */ + public static long fingerprint(long worldSeed, int chunkX, int chunkZ) { + return mix(worldSeed ^ architecture(worldSeed, chunkX, chunkZ).ordinal(), chunkX, chunkZ); + } + + private static void city(WorldGenLevel level, int x0, int z0, Random random) { + int y = 64; + for (int x = x0; x < x0 + 16; x++) for (int z = z0; z < z0 + 16; z++) { + boolean road = Math.floorMod(x, 32) < 5 || Math.floorMod(z, 32) < 5; + set(level, x, y, z, road ? CONCRETE_GRAY : CONCRETE_LIGHT); + if (road && (Math.floorMod(x, 8) == 0 || Math.floorMod(z, 8) == 0)) set(level, x, y, z, Blocks.SMOOTH_STONE.defaultBlockState()); + } + if (Math.floorMod(x0, 32) < 5 || Math.floorMod(z0, 32) < 5) return; + int minX = x0 + 2, maxX = x0 + 13, minZ = z0 + 2, maxZ = z0 + 13; + int height = 18 + random.nextInt(42); + for (int floor = 0; floor <= height; floor++) { + int py = y + 1 + floor; + boolean slab = floor == 0 || floor == height || floor % 5 == 0; + for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) { + boolean edge = x == minX || x == maxX || z == minZ || z == maxZ; + if (slab) set(level, x, py, z, CONCRETE_LIGHT); + else if (edge) set(level, x, py, z, floor % 5 == 1 ? CONCRETE_WHITE : GLASS_CYAN); + } + } + } + + private static void offices(WorldGenLevel level, int x0, int z0, Random random) { + for (int baseY : new int[]{72, 84, 96}) { + for (int x = x0; x < x0 + 16; x++) for (int z = z0; z < z0 + 16; z++) { + set(level, x, baseY, z, Blocks.OAK_PLANKS.defaultBlockState()); + set(level, x, baseY + 9, z, CLAY_ORANGE); + boolean wall = Math.floorMod(x, 12) == 0 || Math.floorMod(z, 12) == 0; + if (wall) for (int y = baseY + 1; y < baseY + 9; y++) { + BlockState material = y == baseY + 4 ? Blocks.STAINED_GLASS.pick(DyeColor.BROWN).defaultBlockState() : CLAY_BROWN; + set(level, x, y, z, material); + } + } + if (random.nextInt(3) == 0) { + int deskX = x0 + 5 + random.nextInt(6), deskZ = z0 + 5 + random.nextInt(6); + set(level, deskX, baseY + 1, deskZ, Blocks.OAK_SLAB.defaultBlockState()); + set(level, deskX + 1, baseY + 1, deskZ, Blocks.BOOKSHELF.defaultBlockState()); + set(level, deskX, baseY + 1, deskZ + 1, Blocks.CARPET.pick(DyeColor.BROWN).defaultBlockState()); + } + } + } + + private static void plains(WorldGenLevel level, int x0, int z0, Random random) { + int baseY = 68 + random.nextInt(3); + for (int x = x0; x < x0 + 16; x++) for (int z = z0; z < z0 + 16; z++) { + set(level, x, baseY - 2, z, Blocks.STONE.defaultBlockState()); + set(level, x, baseY - 1, z, Blocks.DIRT.defaultBlockState()); + set(level, x, baseY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + if (random.nextInt(3) == 0) tree(level, x0 + 4 + random.nextInt(8), baseY + 1, z0 + 4 + random.nextInt(8)); + if (random.nextInt(7) == 0) { + for (int x = x0 + 3; x <= x0 + 11; x++) for (int z = z0 + 3; z <= z0 + 11; z++) { + boolean edge = x == x0 + 3 || x == x0 + 11 || z == z0 + 3 || z == z0 + 11; + if (edge) for (int y = baseY + 1; y <= baseY + 4; y++) set(level, x, y, z, Blocks.MUD_BRICKS.defaultBlockState()); + } + } + } + + private static void aggregates(WorldGenLevel level, int x0, int z0, Random random) { + int baseY = 74 + random.nextInt(10); + platform(level, x0, z0, baseY, CONCRETE_LIGHT); + boxRoom(level, x0 + 2, baseY + 1, z0 + 2, x0 + 12, baseY + 7, z0 + 12, + Blocks.SMOOTH_STONE.defaultBlockState(), Blocks.OAK_PLANKS.defaultBlockState()); + if (random.nextBoolean()) { + int upper = baseY + 20 + random.nextInt(12); + platform(level, x0 + 4, z0 + 4, upper, CLAY_ORANGE); + for (int y = baseY + 1; y <= upper; y++) set(level, x0 + 8, y, z0 + 8, Blocks.IRON_CHAIN.defaultBlockState()); + } + } + + private static void rotated(WorldGenLevel level, int x0, int z0, Random random) { + int y0 = 70 + random.nextInt(12); + for (int x = x0; x < x0 + 16; x++) for (int z = z0; z < z0 + 16; z++) set(level, x, y0, z, CONCRETE_LIGHT); + boolean alongX = random.nextBoolean(); + for (int depth = 2; depth <= 13; depth++) for (int y = y0 + 1; y <= y0 + 13; y++) { + int x = alongX ? x0 + depth : x0 + 3; + int z = alongX ? z0 + 3 : z0 + depth; + BlockState state = (y == y0 + 1 || y == y0 + 13 || depth == 2 || depth == 13) + ? CLAY_BROWN : Blocks.AIR.defaultBlockState(); + set(level, x, y, z, state); + } + // Furniture fixed to the vertical "floor" sells the rotated-room illusion without changing gravity. + for (int offset = 4; offset <= 10; offset += 3) { + int x = alongX ? x0 + offset : x0 + 4; + int z = alongX ? z0 + 4 : z0 + offset; + set(level, x, y0 + 4, z, Blocks.BOOKSHELF.defaultBlockState()); + } + } + + private static void placeLootLandmarks(WorldGenLevel level, int x0, int z0, Random random, Architecture architecture) { + int y = switch (architecture) { + case CITY -> 65; + case OFFICES -> 73; + case PLAINS -> 72; + case AGGREGATES -> 83; + case ROTATED -> 84; + }; + int x = x0 + 7, z = z0 + 7; + while (!level.getBlockState(new BlockPos(x, y, z)).isAir() && y < 180) y++; + while (!level.getBlockState(new BlockPos(x, y - 1, z)).isCollisionShapeFullBlock(level, new BlockPos(x, y - 1, z)) && y > 2) y--; + if (random.nextInt(5) == 0) set(level, x, y, z, Blocks.CHEST.defaultBlockState()); + if (random.nextInt(17) == 0) placeBed(level, x0 + 9, y, z0 + 8); + } + + private static void placeRareAnomaly(WorldGenLevel level, int x0, int z0, Random random) { + if (random.nextInt(96) == 0) { + Block anomaly = BackroomMaterialApi.anomalyMaterial(); + for (int i = 0; i < 5; i++) set(level, x0 + 5 + random.nextInt(6), 66 + random.nextInt(20), + z0 + 5 + random.nextInt(6), anomaly.defaultBlockState()); + } + if (random.nextInt(4_096) == 0 + && withinPlayableArea(x0 + 7, z0 + 7) && withinPlayableArea(x0 + 9, z0 + 9)) { + for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) for (int dz = -1; dz <= 1; dz++) { + set(level, x0 + 8 + dx, 40 + dy, z0 + 8 + dz, Blocks.SMOOTH_STONE.defaultBlockState()); + } + set(level, x0 + 8, 40, z0 + 8, Blocks.DIAMOND_BLOCK.defaultBlockState()); + } + } + + private static void placeBed(WorldGenLevel level, int x, int y, int z) { + if (!withinPlayableArea(x, z) || !withinPlayableArea(x + 1, z)) return; + Block bed = Blocks.BED.pick(DyeColor.WHITE); + BlockState foot = bed.defaultBlockState().setValue(BedBlock.FACING, Direction.EAST).setValue(BedBlock.PART, BedPart.FOOT); + BlockState head = foot.setValue(BedBlock.PART, BedPart.HEAD); + set(level, x, y, z, foot); + set(level, x + 1, y, z, head); + } + + private static void platform(WorldGenLevel level, int x0, int z0, int y, BlockState state) { + for (int x = x0; x < x0 + 16; x++) for (int z = z0; z < z0 + 16; z++) set(level, x, y, z, state); + } + + private static void boxRoom(WorldGenLevel level, int minX, int minY, int minZ, + int maxX, int maxY, int maxZ, BlockState shell, BlockState floor) { + for (int x = minX; x <= maxX; x++) for (int y = minY; y <= maxY; y++) for (int z = minZ; z <= maxZ; z++) { + boolean boundary = x == minX || x == maxX || y == minY || y == maxY || z == minZ || z == maxZ; + if (boundary) set(level, x, y, z, y == minY ? floor : shell); + } + } + + private static void tree(WorldGenLevel level, int x, int y, int z) { + for (int dy = 0; dy < 5; dy++) set(level, x, y + dy, z, Blocks.OAK_LOG.defaultBlockState()); + for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) for (int dy = 3; dy <= 6; dy++) { + if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy - 5) <= 5) set(level, x + dx, y + dy, z + dz, LEAVES); + } + } + + private static void set(WorldGenLevel level, int x, int y, int z, BlockState state) { + if (withinPlayableArea(x, z) && y >= level.getMinY() && y < level.getMaxY()) { + level.setBlock(new BlockPos(x, y, z), state, 2); + } + } + + private static long mix(long seed, int x, int z) { + long value = seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL; + value ^= value >>> 30; + value *= 0xBF58476D1CE4E5B9L; + value ^= value >>> 27; + value *= 0x94D049BB133111EBL; + return value ^ value >>> 31; + } + + public enum Architecture { CITY, OFFICES, PLAINS, AGGREGATES, ROTATED } +} diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 6a56acb..a4603b2 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -516,6 +516,16 @@ "item.sanctuary.blue_scroll_cape": "Blue Scroll Cape", "item.sanctuary.white_cape": "White Cape", "dimension.sanctuary.alpha": "Alpha / Indev Island", + "dimension.sanctuary.backroom": "Backroom", + "message.sanctuary.backroom.unavailable": "The Backroom is unavailable in this world.", + "message.sanctuary.backroom.pending": "Your previous Backroom transfer is waiting for your Mailbox to be loaded.", + "message.sanctuary.backroom.registry_busy": "The Backroom registry is settling too many interrupted transfers. Try again later.", + "message.sanctuary.backroom.mailbox_missing": "Load and register your Mailbox before entering the Backroom.", + "message.sanctuary.backroom.mailbox_full": "Your Mailbox has no room for all your gear, companion egg and cape.", + "message.sanctuary.backroom.entered": "You slip through the bed with no equipment…", + "message.sanctuary.backroom.bed_entry_only": "Only a bed can take you into the Backroom.", + "message.sanctuary.backroom.exited": "The bed returns you to your spawn point with your findings.", + "message.sanctuary.backroom.exited_with_losses": "The bed returns you with your findings and settles the recovered lost items.", "screen.sanctuary.atlas.landmark.boss": "Defeated boss", "message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world." } diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 1724b36..3bcbc65 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -516,6 +516,16 @@ "item.sanctuary.blue_scroll_cape": "Cape parchemin bleu", "item.sanctuary.white_cape": "Cape blanche", "dimension.sanctuary.alpha": "Île Alpha / Indev", + "dimension.sanctuary.backroom": "Backroom", + "message.sanctuary.backroom.unavailable": "La Backroom n’est pas disponible dans ce monde.", + "message.sanctuary.backroom.pending": "Ton ancien transfert Backroom attend que ta Mailbox soit chargée.", + "message.sanctuary.backroom.registry_busy": "Le registre Backroom termine trop de transferts interrompus. Réessaie plus tard.", + "message.sanctuary.backroom.mailbox_missing": "Charge et enregistre ta Mailbox avant d’entrer dans la Backroom.", + "message.sanctuary.backroom.mailbox_full": "Ta Mailbox n’a pas assez de place pour tout ton équipement, l’œuf et la cape.", + "message.sanctuary.backroom.entered": "Tu traverses le lit sans aucun équipement…", + "message.sanctuary.backroom.bed_entry_only": "Seul un lit permet d’entrer dans la Backroom.", + "message.sanctuary.backroom.exited": "Le lit te ramène à ton point d’apparition avec tes trouvailles.", + "message.sanctuary.backroom.exited_with_losses": "Le lit te ramène avec tes trouvailles et valide les objets perdus récupérés.", "screen.sanctuary.atlas.landmark.boss": "Boss vaincu", "message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde." } diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json index 3a90618..e58b506 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json @@ -472,6 +472,16 @@ "item.sanctuary.blue_scroll_cape": "Плащ синего свитка", "item.sanctuary.white_cape": "Белый плащ", "dimension.sanctuary.alpha": "Остров Альфа / Indev", + "dimension.sanctuary.backroom": "Бэкрум", + "message.sanctuary.backroom.unavailable": "Бэкрум недоступен в этом мире.", + "message.sanctuary.backroom.pending": "Предыдущий перенос в Бэкрум ожидает загрузки вашего почтового ящика.", + "message.sanctuary.backroom.registry_busy": "Реестр Бэкрума завершает слишком много прерванных переносов. Повторите попытку позже.", + "message.sanctuary.backroom.mailbox_missing": "Загрузите и зарегистрируйте почтовый ящик перед входом в Бэкрум.", + "message.sanctuary.backroom.mailbox_full": "В почтовом ящике недостаточно места для снаряжения, яйца спутника и плаща.", + "message.sanctuary.backroom.entered": "Вы проваливаетесь сквозь кровать без снаряжения…", + "message.sanctuary.backroom.bed_entry_only": "В Бэкрум можно попасть только через кровать.", + "message.sanctuary.backroom.exited": "Кровать возвращает вас к точке возрождения со всеми находками.", + "message.sanctuary.backroom.exited_with_losses": "Кровать возвращает вас с находками и закрепляет найденные потерянные предметы.", "screen.sanctuary.atlas.landmark.boss": "Побеждённый босс", "message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире." } diff --git a/sanctuary/src/main/resources/data/minecraft/worldgen/world_preset/normal.json b/sanctuary/src/main/resources/data/minecraft/worldgen/world_preset/normal.json index 19b5da1..c3e96c8 100644 --- a/sanctuary/src/main/resources/data/minecraft/worldgen/world_preset/normal.json +++ b/sanctuary/src/main/resources/data/minecraft/worldgen/world_preset/normal.json @@ -22,6 +22,14 @@ "settings": "minecraft:overworld" } }, + "sanctuary:backroom": { + "type": "sanctuary:backroom", + "generator": { + "type": "minecraft:noise", + "biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" }, + "settings": "sanctuary:backroom" + } + }, "minecraft:the_end": { "type": "minecraft:the_end", "generator": { diff --git a/sanctuary/src/main/resources/data/sanctuary/dimension/backroom.json b/sanctuary/src/main/resources/data/sanctuary/dimension/backroom.json new file mode 100644 index 0000000..b7dcc35 --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/dimension/backroom.json @@ -0,0 +1,11 @@ +{ + "type": "sanctuary:backroom", + "generator": { + "type": "minecraft:noise", + "biome_source": { + "type": "minecraft:fixed", + "biome": "sanctuary:backroom" + }, + "settings": "sanctuary:backroom" + } +} diff --git a/sanctuary/src/main/resources/data/sanctuary/dimension_type/backroom.json b/sanctuary/src/main/resources/data/sanctuary/dimension_type/backroom.json new file mode 100644 index 0000000..cd91977 --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/dimension_type/backroom.json @@ -0,0 +1,41 @@ +{ + "ambient_light": 0.35, + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 5000 + } + }, + "minecraft:gameplay/bed_rule": { + "can_set_spawn": "never", + "can_sleep": "never" + }, + "minecraft:gameplay/can_start_raid": false, + "minecraft:gameplay/respawn_anchor_works": false, + "minecraft:gameplay/sky_light_level": 0.0, + "minecraft:gameplay/water_evaporates": false, + "minecraft:visual/ambient_light_color": "#d5d8ce", + "minecraft:visual/fog_color": "#b7b9ad", + "minecraft:visual/fog_end_distance": 112.0, + "minecraft:visual/fog_start_distance": 20.0, + "minecraft:visual/sky_light_color": "#d9dccf", + "minecraft:visual/sky_light_factor": 0.0 + }, + "cardinal_light": "nether", + "coordinate_scale": 1.0, + "has_ceiling": false, + "has_ender_dragon_fight": false, + "has_fixed_time": true, + "has_skylight": false, + "height": 192, + "infiniburn": "#minecraft:infiniburn_overworld", + "logical_height": 192, + "min_y": 0, + "monster_spawn_block_light_limit": 15, + "monster_spawn_light_level": 7, + "skybox": "none", + "timelines": "#minecraft:in_nether" +} diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/backroom.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/backroom.json new file mode 100644 index 0000000..7dd8d55 --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/biome/backroom.json @@ -0,0 +1,32 @@ +{ + "temperature": 0.65, + "downfall": 0.0, + "has_precipitation": false, + "attributes": {}, + "effects": { + "water_color": "#596f75", + "water_fog_color": "#303b3e", + "fog_color": "#b7b9ad", + "sky_color": "#b7b9ad", + "grass_color": "#70845d", + "foliage_color": "#657b58" + }, + "creature_spawn_probability": 0.08, + "spawners": { + "ambient": [], + "axolotls": [], + "creature": [], + "misc": [], + "monster": [ + { "type": "minecraft:zombie", "maxCount": 2, "minCount": 1, "weight": 7 }, + { "type": "minecraft:skeleton", "maxCount": 2, "minCount": 1, "weight": 7 }, + { "type": "minecraft:enderman", "maxCount": 1, "minCount": 1, "weight": 2 } + ], + "underground_water_creature": [], + "water_ambient": [], + "water_creature": [] + }, + "spawn_costs": {}, + "carvers": [], + "features": [[], [], [], [], ["sanctuary:backroom_architecture"], [], [], [], [], [], []] +} diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/configured_feature/backroom_architecture.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/configured_feature/backroom_architecture.json new file mode 100644 index 0000000..8bf9b5e --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/configured_feature/backroom_architecture.json @@ -0,0 +1,4 @@ +{ + "type": "sanctuary:backroom_architecture", + "config": {} +} diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/backroom.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/backroom.json new file mode 100644 index 0000000..b02a172 --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/backroom.json @@ -0,0 +1,37 @@ +{ + "aquifers_enabled": false, + "default_block": { "Name": "minecraft:air" }, + "default_fluid": { "Name": "minecraft:air" }, + "disable_mob_generation": false, + "legacy_random_source": false, + "noise": { + "height": 192, + "min_y": 0, + "size_horizontal": 1, + "size_vertical": 2 + }, + "noise_router": { + "barrier": 0.0, + "continents": 0.0, + "depth": 0.0, + "erosion": 0.0, + "final_density": -1.0, + "fluid_level_floodedness": 0.0, + "fluid_level_spread": 0.0, + "lava": 0.0, + "preliminary_surface_level": 0.0, + "ridges": 0.0, + "temperature": 0.0, + "vegetation": 0.0, + "vein_gap": 0.0, + "vein_ridged": 0.0, + "vein_toggle": 0.0 + }, + "ore_veins_enabled": false, + "sea_level": 63, + "spawn_target": [], + "surface_rule": { + "type": "minecraft:block", + "result_state": { "Name": "minecraft:air" } + } +} diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/backroom_architecture.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/backroom_architecture.json new file mode 100644 index 0000000..080b716 --- /dev/null +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/placed_feature/backroom_architecture.json @@ -0,0 +1,7 @@ +{ + "feature": "sanctuary:backroom_architecture", + "placement": [ + { "type": "minecraft:count", "count": 1 }, + { "type": "minecraft:biome" } + ] +} diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json index 13b190a..5f8a4ff 100644 --- a/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/overworld.json @@ -58,6 +58,14 @@ "settings": "sanctuary:alpha" } }, + "sanctuary:backroom": { + "type": "sanctuary:backroom", + "generator": { + "type": "minecraft:noise", + "biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" }, + "settings": "sanctuary:backroom" + } + }, "minecraft:the_end": { "type": "minecraft:the_end", "generator": { diff --git a/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json b/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json index dca5ff0..f9c8240 100644 --- a/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json +++ b/sanctuary/src/main/resources/data/sanctuary/worldgen/world_preset/sanctuary.json @@ -58,6 +58,14 @@ "settings": "sanctuary:alpha" } }, + "sanctuary:backroom": { + "type": "sanctuary:backroom", + "generator": { + "type": "minecraft:noise", + "biome_source": { "type": "minecraft:fixed", "biome": "sanctuary:backroom" }, + "settings": "sanctuary:backroom" + } + }, "minecraft:the_end": { "type": "minecraft:the_end", "generator": { diff --git a/sanctuary/src/main/resources/fabric.mod.json b/sanctuary/src/main/resources/fabric.mod.json index cbed638..26cb6d9 100644 --- a/sanctuary/src/main/resources/fabric.mod.json +++ b/sanctuary/src/main/resources/fabric.mod.json @@ -32,7 +32,7 @@ "sanctuary26": { "system_type": "gameplay-system", "namespace": "sanctuary", - "data_version": 13, + "data_version": 14, "network_protocol": 24, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", diff --git a/sanctuary/src/main/resources/sanctuary.mixins.json b/sanctuary/src/main/resources/sanctuary.mixins.json index 794af4b..195795a 100644 --- a/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/sanctuary/src/main/resources/sanctuary.mixins.json @@ -4,17 +4,21 @@ "compatibilityLevel": "JAVA_25", "mixins": [ "AbstractContainerMenuMixin", + "BedBlockMixin", + "ChestBlockEntityMixin", "ConsumableMixin", "EntityCompanionMixin", "EntityAirSupplyMixin", "FoodDataMixin", "InventoryMixin", "InventoryMenuMixin", + "ItemEntityMixin", "ItemTooltipMixin", "LivingEntityCompanionMixin", "PlayerOverflowMixin", "SlotMixin", - "ServerGamePacketListenerMixin" + "ServerGamePacketListenerMixin", + "ServerPlayerBackroomMixin" ], "client": [ "client.AbstractButtonMixin", diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomModelSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomModelSmoke.java new file mode 100644 index 0000000..e385155 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/backroom/BackroomModelSmoke.java @@ -0,0 +1,101 @@ +package fr.koka99cab.sanctuary26.sanctuary.backroom; + +import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.BackroomArchitectureFeature; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.RandomSource; + +/** Executable invariants for bounded entry data, personal loot priority and architecture zoning. */ +public final class BackroomModelSmoke { + private BackroomModelSmoke() {} + + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + entryTransactionsAreBounded(); + personalRecoveryIsPrioritized(); + recentLossesRemainPersonal(); + architectureIsDeterministicAndComposed(); + } + + private static void entryTransactionsAreBounded() { + BackroomData data = new BackroomData(); + UUID player = UUID.fromString("c6ff53b1-8b3e-4d7d-9a46-81ef366b02ba"); + require(BackroomData.MAX_ENTRY_SLOTS == 64, "Entry snapshot bound no longer covers exactly one player inventory"); + BackroomData.PendingEntry first = data.prepareEntry(player, List.of()).orElseThrow(); + BackroomData.PendingEntry replacement = data.prepareEntry(player, List.of()).orElseThrow(); + require(!first.transaction().equals(replacement.transaction()), "Reprepared entry reused its transaction ID"); + require(data.pendingEntry(player).orElseThrow().slots().isEmpty(), + "A player has more than one pending entry transaction"); + data.finishEntry(player); + require(data.pendingEntry(player).isEmpty(), "Finished entry transaction remained persisted"); + for (int index = 0; index < BackroomData.MAX_PENDING_ENTRIES; index++) { + require(data.prepareEntry(new UUID(0L, index + 1L), List.of()).isPresent(), + "Pending-entry registry filled before its documented bound"); + } + require(data.prepareEntry(new UUID(1L, 1L), List.of()).isEmpty(), + "A new entry evicted an unresolved Mailbox transaction"); + + for (int index = 0; index < BackroomData.MAX_RECENT_ARRIVALS + 12; index++) { + data.beginExpedition(player, index); + } + require(data.recentArrivals(player).size() == BackroomData.MAX_RECENT_ARRIVALS, + "Arrival history exceeded its persistence bound"); + } + + private static void personalRecoveryIsPrioritized() { + RandomSource first = RandomSource.create(26_200_132L); + RandomSource second = RandomSource.create(26_200_133L); + RandomSource later = RandomSource.create(26_200_134L); + int firstHits = 0, secondHits = 0, laterHits = 0; + for (int sample = 0; sample < 20_000; sample++) { + if (BackroomChestService.personalRoll(first, 1)) firstHits++; + if (BackroomChestService.personalRoll(second, 2)) secondHits++; + if (!BackroomChestService.personalRoll(second, 3)) throw new AssertionError("Third personal chest is not guaranteed"); + if (BackroomChestService.personalRoll(later, 4)) laterHits++; + } + require(firstHits > 14_500 && firstHits < 15_500, "First-chest personal chance drifted from 75%"); + require(secondHits > 17_500 && secondHits < 18_500, "Second-chest personal chance drifted from 90%"); + require(laterHits > 12_500 && laterHits < 13_500, "Later personal chance drifted from 65%"); + require(BackroomChestService.REFRESH_TICKS == 12_000L, "Chest renewal is no longer twice per Minecraft day"); + } + + private static void recentLossesRemainPersonal() { + require(!BackroomData.sharedEligible("owner", 10_000L, + 10_000L + BackroomData.PERSONAL_RESERVATION_TICKS - 1L), + "A recent owned loss escaped into historical loot"); + require(BackroomData.sharedEligible("owner", 10_000L, + 10_000L + BackroomData.PERSONAL_RESERVATION_TICKS), + "An old owned loss never became historical loot"); + require(BackroomData.sharedEligible("", 10_000L, 10_000L), + "An ownerless loss was not immediately eligible for shared loot"); + } + + private static void architectureIsDeterministicAndComposed() { + long seed = 26_200_132L; + EnumSet families = + EnumSet.noneOf(BackroomArchitectureFeature.Architecture.class); + for (int macroX = -12; macroX <= 12; macroX++) for (int macroZ = -12; macroZ <= 12; macroZ++) { + int chunkX = macroX * 8, chunkZ = macroZ * 8; + BackroomArchitectureFeature.Architecture family = + BackroomArchitectureFeature.architecture(seed, chunkX, chunkZ); + families.add(family); + require(family == BackroomArchitectureFeature.architecture(seed, chunkX + 7, chunkZ + 7), + "One macro cell mixed unrelated architecture palettes"); + require(BackroomArchitectureFeature.fingerprint(seed, chunkX, chunkZ) + == BackroomArchitectureFeature.fingerprint(seed, chunkX, chunkZ), + "Architecture fingerprint is not deterministic"); + } + require(families.size() == BackroomArchitectureFeature.Architecture.values().length, + "Not all authored architecture families are reachable"); + require(BackroomArchitectureFeature.withinPlayableArea(2_000, 0), "Playable boundary moved inward"); + require(!BackroomArchitectureFeature.withinPlayableArea(2_001, 0), "Architecture escaped the 2,000-block zone"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} -- 2.54.0