feat: ajouter la dimension persistante Backroom #133

Merged
koka merged 1 commits from feature/backroom into main 2026-09-01 14:50:33 +00:00
40 changed files with 1877 additions and 70 deletions
+12
View File
@@ -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 saccroupissant 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 dentré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 dAnother World aux anomalies et les blocs précieux à de rares cachettes ;
- fait enregistrer la mousse turquoise par Another World via lAPI 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.
+32 -2
View File
@@ -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")
}
@@ -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();
@@ -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": {
+51 -44
View File
@@ -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"]
+2 -2
View File
@@ -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
@@ -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"
]
}
}
+65
View File
@@ -0,0 +1,65 @@
# Backroom
La Backroom est une dimension persistante de récupération et de construction. Elle nest 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 lorigine.
## Entrer et sortir
- Saccroupir et utiliser un lit hors de la Backroom déclenche lentrée, sans remplacer lusage
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 nest chargée, ou si elle ne peut pas recevoir lintégralité du
colis, rien ne bouge et lentrée est refusée. Une Mailbox construite dans la Backroom nest jamais
une destination dentrée et ne permet donc pas dimporter du matériel.
- Lidentifiant 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 na
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 dapparition global sil nexiste 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 lextérieur
reste possible pour ladministration, mais replace dabord 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 quil 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 à lenvers, sans modifier la gravité globale.
Les matériaux très marqués dAnother World napparaissent 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 <joueur>` pour contrôler les registres sans les modifier.
+90 -16
View File
@@ -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")
@@ -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();
@@ -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;
}
}
@@ -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<UUID> 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<UUID> 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<UUID> markers(ChestGroup group) {
ArrayList<UUID> 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<ChestBlockEntity> 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;
}
}
}
@@ -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<EntrySlot> 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<PendingEntry> 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<LostItem> 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<ChestState> 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<PlayerState> 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<BackroomData> 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<BackroomData> TYPE = new SavedDataType<>(
SanctuaryMod.id(SAVE_KEY), BackroomData::new, CODEC, null);
private int dataVersion;
private final List<PendingEntry> pendingEntries;
private final List<LostItem> lostItems;
private final List<ChestState> chests;
private final Map<Long, Integer> chestIndices;
private final List<PlayerState> players;
public BackroomData() {
this(DATA_VERSION, List.of(), List.of(), List.of(), List.of());
}
private BackroomData(int dataVersion, List<PendingEntry> pendingEntries, List<LostItem> lostItems,
List<ChestState> chests, List<PlayerState> 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> pendingEntry(UUID player) {
String id = player.toString();
return pendingEntries.stream().filter(entry -> entry.player.equals(id)).findFirst();
}
public Optional<PendingEntry> prepareEntry(UUID player, List<EntrySlot> 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<UUID> 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<MaterializedLoss> 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<MaterializedLoss> 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<MaterializedLoss> 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<UUID> markersStillPresent) {
List<String> 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<ChestState> 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<UUID> 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<Long> 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<Long> 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<Long> 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<Long> visited, List<Long> 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<EntrySlot> copySlots(List<EntrySlot> 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 <T> ArrayList<T> bounded(List<T> source, int maximum) {
List<T> 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<EntrySlot> 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<String> assigned) {
public List<UUID> assignedIds() { return assigned.stream().map(UUID::fromString).toList(); }
}
private record PlayerState(String player, int personalAttempts, List<Long> visitedChests,
List<Long> recentArrivals) {}
public record MaterializedLoss(UUID id, ItemStack stack) {}
public record ChestVisit(boolean firstVisit, int ordinal) {}
}
@@ -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<BackroomData.EntrySlot> snapshot(ServerPlayer player) {
ArrayList<BackroomData.EntrySlot> 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<ItemStack> parcel(List<BackroomData.EntrySlot> 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<BackroomData.EntrySlot> 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<BackroomData.EntrySlot> 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 -> { }
}
}
}
@@ -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<UUID> 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<BackroomData.EntrySlot> 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<Long> 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<Long> 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;
}
}
@@ -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<InteractionResult> callback) {
if (player instanceof ServerPlayer serverPlayer && BackroomService.useBed(serverPlayer)) {
callback.setReturnValue(InteractionResult.SUCCESS_SERVER);
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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<ServerPlayer> 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");
}
}
}
@@ -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<UUID> BACKROOM_LOSS_COMPONENT = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
BACKROOM_LOSS_ID,
DataComponentType.<UUID>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,
@@ -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<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private final Set<UUID> 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<ItemStack> deliveries) {
if (transactionId == null || deliveries == null) return false;
if (processedTransactions.contains(transactionId)) return true;
NonNullList<ItemStack> 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<ItemStack> copyItems(List<ItemStack> source) {
NonNullList<ItemStack> 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<ItemStack> 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<ItemStack> getItems() { return items; }
@@ -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<DeliveryDestination> loadedDeliveryBox(MinecraftServer server, UUID owner) {
return loadedDeliveryBox(server, owner, level -> true);
}
public Optional<DeliveryDestination> loadedDeliveryBox(MinecraftServer server, UUID owner,
Predicate<ServerLevel> allowedLevel) {
String ownerId = owner.toString();
boolean changed = false;
Iterator<DeliveryAddress> 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())) {
@@ -26,6 +26,7 @@ public final class SanctuaryDimensions {
public static final ResourceKey<Level> PAINTED_CANYONS = dimension("painted_canyons");
public static final ResourceKey<Level> BREAKER_SEA = dimension("breaker_sea");
public static final ResourceKey<Level> LAPUTA = dimension("laputa");
public static final ResourceKey<Level> BACKROOM = dimension("backroom");
public static final ResourceKey<LevelStem> MAIN_STEM = stem("main");
public static final ResourceKey<LevelStem> OVERWORLD_STEM = stem("overworld");
@@ -37,11 +38,12 @@ public final class SanctuaryDimensions {
public static final ResourceKey<LevelStem> PAINTED_CANYONS_STEM = stem("painted_canyons");
public static final ResourceKey<LevelStem> BREAKER_SEA_STEM = stem("breaker_sea");
public static final ResourceKey<LevelStem> LAPUTA_STEM = stem("laputa");
public static final ResourceKey<LevelStem> BACKROOM_STEM = stem("backroom");
public static final Set<Identifier> 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() {
@@ -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<NoneFeatureConfiguration> BACKROOM_ARCHITECTURE =
new BackroomArchitectureFeature(NoneFeatureConfiguration.CODEC);
public static final Identifier ALPHA_INDEV_CHUNK_ID = SanctuaryMod.id("alpha_indev_chunk");
public static final Feature<NoneFeatureConfiguration> 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);
@@ -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<NoneFeatureConfiguration> {
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<NoneFeatureConfiguration> codec) {
super(codec);
}
@Override
public boolean place(FeaturePlaceContext<NoneFeatureConfiguration> 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 }
}
@@ -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."
}
@@ -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 nest 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 dentrer dans la Backroom.",
"message.sanctuary.backroom.mailbox_full": "Ta Mailbox na 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 dentrer dans la Backroom.",
"message.sanctuary.backroom.exited": "Le lit te ramène à ton point dapparition 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."
}
@@ -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": "Нотч-Титан побеждён. Победа навсегда записана в этом мире."
}
@@ -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": {
@@ -0,0 +1,11 @@
{
"type": "sanctuary:backroom",
"generator": {
"type": "minecraft:noise",
"biome_source": {
"type": "minecraft:fixed",
"biome": "sanctuary:backroom"
},
"settings": "sanctuary:backroom"
}
}
@@ -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"
}
@@ -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"], [], [], [], [], [], []]
}
@@ -0,0 +1,4 @@
{
"type": "sanctuary:backroom_architecture",
"config": {}
}
@@ -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" }
}
}
@@ -0,0 +1,7 @@
{
"feature": "sanctuary:backroom_architecture",
"placement": [
{ "type": "minecraft:count", "count": 1 },
{ "type": "minecraft:biome" }
]
}
@@ -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": {
@@ -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": {
+1 -1
View File
@@ -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}",
@@ -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",
@@ -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<BackroomArchitectureFeature.Architecture> 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);
}
}