feat: livrer et migrer le Magic Carpet après Notch #121

Merged
koka merged 1 commits from integration/magic-carpet-reward into main 2026-08-31 04:59:18 +00:00
39 changed files with 949 additions and 151 deletions
+7 -3
View File
@@ -8,14 +8,18 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
## Non distribué
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **Sanctuary `0.0.0-alpha.126`** et **MasterKey `0.0.0-alpha.9`**. Le pack public reste temporairement en `26.2.0-alpha.223` jusqu'à la publication.
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.127`** et **MasterKey `0.0.0-alpha.9`**. Le pack public reste temporairement en `26.2.0-alpha.223` jusqu'à la publication.
- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ;
- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ;
- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ;
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve un drapeau de récompense unique pour la suite ;
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve une récompense physique unique ;
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
- migre les données It's Alive de v3 à v4 et Sanctuary de v11 à v12, sans changer les protocoles réseau ni le pack public.
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13, sans changer les protocoles réseau ni le pack public.
## `26.2.0-alpha.223`
+1 -1
View File
@@ -30,7 +30,7 @@ Lalpha.223 ajoute le tri natif des rangées de stockage par clic molette sans
Lalpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions dIDs ditems et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
Le lot non distribué ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables.
Le lot non distribué ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox.
Lalpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
+154 -40
View File
@@ -2915,11 +2915,11 @@ tasks.register("verifyNotchEncounter") {
def textureHash = MessageDigest.getInstance("SHA-256").digest(texture.bytes).encodeHex().toString()
if (rootProject.itsalive_version != "0.0.0-alpha.37"
|| rootProject.ouch_version != "0.0.0-alpha.11"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| rootProject.masterkey_version != "0.0.0-alpha.9"
|| rootProject.pack_version != "26.2.0-alpha.223"
|| manifests.itsalive.custom?.sanctuary26?.data_version != 4
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 12
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.ouch.depends?.itsalive != ">=0.0.0-alpha.37"
|| !registry.contains('Identifier.fromNamespaceAndPath("portals", "notch")')
|| !resident.contains("setCanonicalUuid(canonical.getUUID())")
@@ -2964,6 +2964,112 @@ tasks.named("verifyWorkspace") {
dependsOn tasks.named("verifyCavernsGeology")
}
tasks.register("verifyMagicCarpet") {
group = "verification"
description = "Checks CARPET-01 through CARPET-03 reward, migration, platform and Shop contracts."
inputs.file(file("iliketomoveit/MAGIC_CARPET.md"))
inputs.file(file("pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json"))
inputs.files(fileTree("iliketomoveit/src/main/java") { include "**/*MagicCarpet*.java", "**/*NotchCarpet*.java" })
inputs.files(fileTree("sanctuary/src/main/java") {
include "**/NotchVictory*.java", "**/SanctuaryNotchMilestone.java",
"**/SanctuaryWorldMilestones.java", "**/SanctuaryItemProvenance.java", "**/ShopService.java"
})
inputs.files(fileTree("onlyfun/src/main/java") { include "**/OnlyFunBossShopGate.java", "**/OnlyFunShopCatalog.java" })
inputs.files(["iliketomoveit", "onlyfun", "sanctuary"].collect {
file("${it}/src/main/resources/fabric.mod.json")
})
inputs.files(["fr_fr", "en_us", "ru_ru"].collect {
file("iliketomoveit/src/main/resources/assets/iliketomoveit/lang/${it}.json")
})
inputs.file(file("iliketomoveit/src/main/resources/assets/portals/items/notch_totem.json"))
doLast {
def migration = new JsonSlurper().parse(file(
"pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json"))
def manifests = ["iliketomoveit", "onlyfun", "sanctuary"].collectEntries { id ->
[(id): new JsonSlurper().parse(file("${id}/src/main/resources/fabric.mod.json"))]
}
def reward = file("iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/NotchCarpetRewardService.java").text
def migrationJava = file("iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigration.java").text
def controller = file("iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetController.java").text
def ledger = file("iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedger.java").text
def items = file("iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/registry/ILikeToMoveItItems.java").text
def mixins = new JsonSlurper().parse(file("iliketomoveit/src/main/resources/iliketomoveit.mixins.json"))
def victory = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java").text
def milestones = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/progression/SanctuaryWorldMilestones.java").text
def provenance = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/item/SanctuaryItemProvenance.java").text
def gate = file("onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunBossShopGate.java").text
def shop = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.17"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5
|| manifests.iliketomoveit.depends?.sanctuary != ">=0.0.0-alpha.127"
|| manifests.onlyfun.custom?.sanctuary26?.data_version != 2
|| manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3
|| manifests.onlyfun.depends?.iliketomoveit != ">=0.0.0-alpha.17"
|| manifests.onlyfun.depends?.sanctuary != ">=0.0.0-alpha.127"
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22
|| migration.status != "unreleased"
|| migration.source?.pack_version != "26.2.0-alpha.223"
|| migration.target?.pack_version != "26.2.0-alpha.223"
|| migration.magic_carpet?.canonical_item != "iliketomoveit:magic_carpet"
|| migration.magic_carpet?.legacy_item != "portals:notch_totem"
|| migration.magic_carpet?.legacy_locations != ["player_inventory", "container", "item_entity"]
|| migration.magic_carpet?.temporary_platform?.size != [3, 1, 3]
|| migration.magic_carpet?.temporary_platform?.air_only_placement != true
|| migration.magic_carpet?.temporary_platform?.overlap_owner_tracking != true
|| migration.notch_reward?.maximum_canonical_initial_rewards != 1
|| migration.notch_reward?.idempotent_recovery != true
|| migration.shop_replacement?.hidden_before_notch != true
|| migration.shop_replacement?.server_authoritative_gate != true
|| migration.shop_replacement?.mailbox_delivery != true
|| migration.shop_replacement?.recreates_initial_reward != false
|| migration.compatibility?.iliketomoveit_data_version != 4
|| migration.compatibility?.sanctuary_data_version != 13
|| migration.compatibility?.sanctuary_notch_state_data_version != 2
|| !items.contains('Identifier.fromNamespaceAndPath("portals", "notch_totem")')
|| !migrationJava.contains("transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET")
|| !migrationJava.contains("stampIfAbsent")
|| !mixins.mixins.containsAll(["MagicCarpetContainerMixin", "MagicCarpetItemEntityMixin"])
|| !reward.contains("claimInitialReward") || !reward.contains("completeInitialReward")
|| !reward.contains("Blocks.CHEST") || !reward.contains("FireworkRocketEntity")
|| !reward.contains("SanctuaryItemProvenance.NOTCH_REWARD")
|| !victory.contains('optionalFieldOf("initial_reward_delivered", false)')
|| !controller.contains("if (current.isAir())")
|| !controller.contains("resetFallDistance")
|| controller.contains("mayfly") || controller.contains("flying")
|| !ledger.contains("trackedByAnother") || !ledger.contains("drain()")
|| !milestones.contains("SanctuaryNotchMilestone.isDefeated(server)")
|| !gate.contains("SanctuaryWorldMilestones.isReached")
|| !gate.contains("IMPLEMENTED_MILESTONES")
|| !shop.contains("SanctuaryItemProvenance.SHOP_PURCHASE")
|| !shop.contains("reward_recreated=false")
|| !provenance.contains('ORIGIN_KEY = "sanctuary_origin"')) {
throw new GradleException("The CARPET-01 through CARPET-03 contract is incomplete")
}
["fr_fr", "en_us", "ru_ru"].each { locale ->
def values = new JsonSlurper().parse(file(
"iliketomoveit/src/main/resources/assets/iliketomoveit/lang/${locale}.json"))
def required = ["item.iliketomoveit.magic_carpet", "item.portals.notch_totem",
"item.iliketomoveit.magic_carpet.description",
"item.iliketomoveit.magic_carpet.origin.notch",
"item.iliketomoveit.magic_carpet.origin.shop",
"message.iliketomoveit.magic_carpet.reward_created"]
if (!values.keySet().containsAll(required)) {
throw new GradleException("Missing ${locale} Magic Carpet translations")
}
}
}
}
tasks.named("verifyWorkspace") {
dependsOn tasks.named("verifyMagicCarpet")
}
tasks.register("verifyWorldgenVisibility") {
group = "verification"
description = "Checks the alpha.179 Sulfur Cave, stone and Cobble Dungeon visibility contract."
@@ -3523,8 +3629,8 @@ tasks.register("verifyProgressivePauseMenuRelease") {
"sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.onlyfun_version != "0.0.0-alpha.25"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| 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"]
|| migration.target?.modules != [sanctuary: "0.0.0-alpha.109", onlyfun: "0.0.0-alpha.17"]
@@ -3835,8 +3941,9 @@ project(":onlyfun") {
def shitCubeRecipe = new JsonSlurper().parse(file(
"src/main/resources/data/abjection/recipe/shit_cube.json"))
if (manifest.custom.sanctuary26.data_version != 2
|| project.version.toString() != "0.0.0-alpha.25"
|| manifest.depends?.sanctuary != "*"
|| project.version.toString() != "0.0.0-alpha.26"
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127"
|| manifest.depends?.iliketomoveit != ">=0.0.0-alpha.17"
|| manifest.provides != ["abjection"]
|| manifest.custom.sanctuary26.network_protocol != 3
|| !javaText.contains('NAMESPACE = "abjection"')
@@ -4364,8 +4471,8 @@ tasks.register("verifyShopPricingLayoutRelease") {
def release = new JsonSlurper().parse(file("pack/release.json"))
def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json"))
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.onlyfun_version != "0.0.0-alpha.25"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"
@@ -4458,8 +4565,8 @@ tasks.register("verifyShopCsvBossGatesRelease") {
def catalog = file("onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalog.java").text
def offerCatalog = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.onlyfun_version != "0.0.0-alpha.25"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"]
@@ -4478,7 +4585,8 @@ tasks.register("verifyShopCsvBossGatesRelease") {
|| csvLines.size() != 1985
|| !catalog.contains("ShopCatalogCsv.load()")
|| !catalog.contains("OnlyFunBossShopGate.isAvailable")
|| !bossGate.contains("BossUnlockResolver LOCKED")
|| !bossGate.contains("IMPLEMENTED_MILESTONES")
|| !bossGate.contains("SanctuaryWorldMilestones.isReached")
|| !bossGate.contains("MAGIC_CLOUD, MAGIC_CARPET")
|| !offerCatalog.contains("currentProvider.isAvailable(server, itemId)")) {
throw new GradleException("The alpha.195 CSV Shop and boss-gate release contract is incomplete")
@@ -4551,7 +4659,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.223"
|| rootProject.itsalive_version != "0.0.0-alpha.37"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"]
@@ -4651,7 +4759,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.223"
|| rootProject.itsalive_version != "0.0.0-alpha.37"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"
@@ -4771,7 +4879,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.223"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"
@@ -4783,7 +4891,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 != 12
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 13
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 22
|| release.pack_version != "26.2.0-alpha.223"
|| release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack"
@@ -4827,8 +4935,8 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") {
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.anotherworld_version != "0.0.0-alpha.55"
|| rootProject.itsalive_version != "0.0.0-alpha.37"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.16"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| migration.source?.pack_version != "26.2.0-alpha.201"
|| migration.source?.modules != [
itsalive: "0.0.0-alpha.30",
@@ -4860,9 +4968,9 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") {
|| migration.compatibility?.registry_ids_changed != false
|| itsAliveManifest.custom?.sanctuary26?.data_version != 4
|| itsAliveManifest.custom?.sanctuary26?.network_protocol != 4
|| moveItManifest.custom?.sanctuary26?.data_version != 3
|| moveItManifest.custom?.sanctuary26?.data_version != 4
|| moveItManifest.custom?.sanctuary26?.network_protocol != 5
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 12
|| sanctuaryManifest.custom?.sanctuary26?.data_version != 13
|| sanctuaryManifest.custom?.sanctuary26?.network_protocol != 22
|| release.pack_version != "26.2.0-alpha.223"
|| release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack"
@@ -4986,7 +5094,7 @@ tasks.register("verifySafeVaultFeature") {
}.keySet()
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.anotherworld_version != "0.0.0-alpha.55"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| migration.schema_version != 1
|| migration.source?.pack_version != "26.2.0-alpha.201"
|| migration.target?.pack_version != "26.2.0-alpha.202"
@@ -5729,7 +5837,7 @@ tasks.register("verifyCanapliaAlpha209Release") {
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.ambiance_version != "0.0.0-alpha.22"
|| rootProject.itsalive_version != "0.0.0-alpha.37"
|| rootProject.onlyfun_version != "0.0.0-alpha.25"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| migration.source?.pack_version != "26.2.0-alpha.207"
|| migration.source?.modules != expectedSource
|| migration.target?.pack_version != "26.2.0-alpha.208"
@@ -5845,9 +5953,9 @@ tasks.register("verifyAlpha210Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.198.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.onlyfun_version != "0.0.0-alpha.25"
|| rootProject.iliketomoveit_version != currentModules.iliketomoveit
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.17"
|| rootProject.itsalive_version != "0.0.0-alpha.37"
|| migration.source?.pack_version != "26.2.0-alpha.209"
|| migration.source?.modules != expectedSource
@@ -5882,11 +5990,11 @@ 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 != 12
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22
|| manifests.onlyfun.custom?.sanctuary26?.data_version != 2
|| manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 3
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5
|| manifests.itsalive.custom?.sanctuary26?.data_version != 4
|| manifests.itsalive.custom?.sanctuary26?.network_protocol != 4
@@ -5945,9 +6053,10 @@ tasks.register("verifyAlpha211Release") {
ambiance: "0.0.0-alpha.22",
iliketomoveit: "0.0.0-alpha.16"]
def workspaceModules = currentModules + [
sanctuary: "0.0.0-alpha.126", itsalive: "0.0.0-alpha.37", ouch: "0.0.0-alpha.11"]
sanctuary: "0.0.0-alpha.127", onlyfun: "0.0.0-alpha.26",
iliketomoveit: "0.0.0-alpha.17", itsalive: "0.0.0-alpha.37", ouch: "0.0.0-alpha.11"]
def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11]
def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 12]
def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 13]
def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22]
def manifests = expectedTarget.collectEntries { id, version ->
[(id): new JsonSlurper().parse(file("${id}/src/main/resources/fabric.mod.json"))]
@@ -6044,7 +6153,7 @@ tasks.register("verifyAlpha212Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.198.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"
@@ -6062,7 +6171,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 != 12
|| manifest.custom?.sanctuary26?.data_version != 13
|| manifest.custom?.sanctuary26?.network_protocol != 22
|| !minecraftMixin.contains('method = "pickBlockOrEntity"')
|| release.pack_version != "26.2.0-alpha.223"
@@ -6534,10 +6643,14 @@ tasks.register("verifyAlpha218Release") {
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.redstoner_version != "0.0.0-alpha.13"
|| currentModules.findAll { id, version -> id != "redstoner" && id != "sanctuary" }.any {
|| currentModules.findAll { id, version ->
id != "redstoner" && id != "sanctuary" && id != "onlyfun" && id != "iliketomoveit"
}.any {
id, version -> rootProject.property("${id}_version") != version
}
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.onlyfun_version != "0.0.0-alpha.26"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.17"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| migration.source?.pack_version != "26.2.0-alpha.217"
|| migration.source?.modules != sourceModules
|| migration.target?.pack_version != "26.2.0-alpha.218"
@@ -6561,7 +6674,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 != 12
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22
|| !rules.contains("MAX_CONTAINERS = 128")
|| !rules.contains("PAGE_SIZE = 54")
@@ -6621,7 +6734,8 @@ tasks.register("verifyAlpha219Release") {
sanctuary: "0.0.0-alpha.122", iliketomoveit: "0.0.0-alpha.16", ambiance: "0.0.0-alpha.21"]
def currentModules = targetModules + [
ambiance: "0.0.0-alpha.22", sanctuary: "0.0.0-alpha.125"]
def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.126"]
def workspaceModules = currentModules + [
sanctuary: "0.0.0-alpha.127", iliketomoveit: "0.0.0-alpha.17"]
def requiredJars = currentModules.collect { id, version ->
"file = \"mods/${id}-${version}.jar\""
}
@@ -6661,9 +6775,9 @@ 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 != 12
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 3
|| manifests.iliketomoveit.custom?.sanctuary26?.data_version != 4
|| manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5
|| manifests.ambiance.custom?.sanctuary26?.data_version != 2
|| manifests.ambiance.custom?.sanctuary26?.network_protocol != 6
@@ -6946,7 +7060,7 @@ tasks.register("verifyAlpha222Release") {
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.redstoner_version != "0.0.0-alpha.13"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| migration.source?.pack_version != "26.2.0-alpha.221"
|| migration.source?.modules != [
redstoner: "0.0.0-alpha.12", sanctuary: "0.0.0-alpha.122"]
@@ -6988,7 +7102,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 != 12
|| manifests.sanctuary.custom?.sanctuary26?.data_version != 13
|| manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22
|| !rules.contains("MAX_SEARCH_LENGTH = 32")
|| !rules.contains("compactCount(int count)")
@@ -7104,7 +7218,7 @@ tasks.register("verifyAlpha223Release") {
it.path == "mods/jei-26.2-fabric-30.29.0.198.jar"
}
if (rootProject.pack_version != "26.2.0-alpha.223"
|| rootProject.sanctuary_version != "0.0.0-alpha.126"
|| rootProject.sanctuary_version != "0.0.0-alpha.127"
|| 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"
@@ -7140,7 +7254,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 != 12
|| manifest.custom?.sanctuary26?.data_version != 13
|| manifest.custom?.sanctuary26?.network_protocol != 22
|| !inventorySort.contains("COOLDOWN_TICKS = 8")
|| !inventorySort.contains("if (row == 0) continue")
+3 -3
View File
@@ -24,11 +24,11 @@ ouch_version=0.0.0-alpha.11
ouch_lifecycle=active
masterkey_version=0.0.0-alpha.9
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.16
iliketomoveit_version=0.0.0-alpha.17
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.25
onlyfun_version=0.0.0-alpha.26
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.126
sanctuary_version=0.0.0-alpha.127
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.223
maven_group=fr.koka99cab.sanctuary26
+66
View File
@@ -0,0 +1,66 @@
# Magic Carpet et Totem de Notch
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
catalogue et applique le jalon avant d'exposer l'offre.
## Identités et provenance
| Rôle | ID | Statut |
|---|---|---|
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
convertie une seconde fois.
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
diagnostic et à l'infobulle.
## Première récompense
Après la première victoire mondiale, Sanctuary réserve atomiquement la
récompense puis I Like To Move It crée un coffre déterministe près de la maison
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
créé ce coffre.
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
Une reprise entre les deux relance la création de façon idempotente ; un coffre
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
l'état livré afin de ne rien dupliquer.
## Plateforme temporaire
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
dégât de chute est réinitialisé tant que la plateforme est active.
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
changement de dimension et pendant l'arrêt normal du serveur.
## Remplacement au Shop
Only Fun associe le Magic Carpet au resolver public
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
Après la victoire, elle rejoint la rotation normale au prix configuré de
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
paient chaque remplacement et la Mailbox effectue la livraison différée.
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
et ne touche jamais aux états de réservation ou de coffre de la récompense
initiale.
+19 -10
View File
@@ -32,6 +32,14 @@ tasks.register("alphaPortalContractSmoke", JavaExec) {
dependsOn tasks.named("testClasses")
}
tasks.register("magicCarpetLedgerSmoke", JavaExec) {
group = "verification"
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
dependsOn tasks.named("testClasses")
}
tasks.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -100,11 +108,11 @@ tasks.register("verifyWaystones") {
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 3
if (manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -203,9 +211,9 @@ tasks.register("verifyGrappling") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|| manifest.custom?.sanctuary26?.data_version != 3
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
throw new GradleException("I Like To Move It must keep aggregate data v3/protocol 5 and register its common/client mixins")
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -313,7 +321,7 @@ tasks.register("verifyPortals") {
}
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -349,7 +357,7 @@ tasks.register("verifySilverAircraft") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -378,7 +386,7 @@ tasks.register("verifyGrapplePrediction") {
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
@@ -410,7 +418,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
def teleportation = file(
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
@@ -444,7 +452,7 @@ tasks.register("verifyAlphaPortal") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|| route.destination_dimension != "sanctuary:alpha"
|| route.destination_resolver != "fixed"
@@ -466,6 +474,7 @@ tasks.register("verifyAlphaPortal") {
tasks.named("check") {
dependsOn tasks.named("grapplePhysicsSmoke")
dependsOn tasks.named("magicCarpetLedgerSmoke")
dependsOn tasks.named("waystoneElevatorRulesSmoke")
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
dependsOn tasks.named("verifyWaystones")
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
public final class ILikeToMoveItMod implements ModInitializer {
public static final String MOD_ID = "iliketomoveit";
public static final String DISPLAY_NAME = "I Like To Move It";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ILikeToMoveItMod() {
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
GrappleController.register();
PulleyHeadController.register();
MagicCarpetController.register();
MagicCarpetMigrationService.register();
NotchCarpetRewardService.register();
WaystoneElevatorController.initialize();
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.iliketomoveit.item;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
public final class MagicCarpetItem extends Item {
public MagicCarpetItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
.withStyle(ChatFormatting.GRAY));
var origin = SanctuaryItemProvenance.origin(stack);
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
.withStyle(ChatFormatting.GOLD));
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
.withStyle(ChatFormatting.AQUA));
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@Override
public boolean isFoil(ItemStack stack) {
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|| super.isFoil(stack);
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.Container;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.storage.ValueInput;
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;
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
@Mixin(BaseContainerBlockEntity.class)
abstract class MagicCarpetContainerMixin {
@Inject(method = "loadAdditional", at = @At("TAIL"))
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
MagicCarpetMigration.migrateContainer((Container) (Object) this);
}
}
@@ -0,0 +1,21 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
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;
/** Converts historical Totems already present as loaded item entities. */
@Mixin(ItemEntity.class)
abstract class MagicCarpetItemEntityMixin {
@Inject(method = "tick", at = @At("HEAD"))
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
ItemEntity self = (ItemEntity) (Object) this;
ItemStack current = self.getItem();
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
if (replacement != current) self.setItem(replacement);
}
}
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
public static final Item MAGIC_CARPET = register("magic_carpet",
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
public static final Item LEGACY_NOTCH_TOTEM = register(
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
public static final Item GRAPPLING_HAND = register("grappling_hand",
key -> new Item(new Item.Properties().setId(key)));
public static final Item GRAPPLING_ROD = register("grappling_rod",
@@ -1,10 +1,7 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
private static final int RADIUS = 1;
private static final Block CARPET_BLOCK = Blocks.GLASS;
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
private MagicCarpetController() {
}
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
connected.add(id);
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
if (active) {
if (!ACTIVE_CARPETS.containsKey(id)) {
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
if (!ACTIVE_CARPETS.contains(id)) {
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
}
updateCarpet(id, player);
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
deactivate(server, id, true);
}
}
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, CarpetState> entry = iterator.next();
if (!connected.contains(entry.getKey())) {
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
iterator.remove();
for (UUID owner : ACTIVE_CARPETS.owners()) {
if (!connected.contains(owner)) {
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state != null) removePositions(server, owner, state, state.positions());
}
}
}
private static boolean isMagicCarpet(ItemStack stack) {
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
return MagicCarpetMigration.isAnyCarpet(stack);
}
private static void updateCarpet(UUID owner, ServerPlayer player) {
ServerLevel level = player.level();
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
if (!previous.dimension().equals(level.dimension())) {
removePositions(level.getServer(), owner, previous, previous.positions());
previous = CarpetState.empty(level.dimension());
ACTIVE_CARPETS.remove(owner);
previous = ACTIVE_CARPETS.state(owner, level.dimension());
} else {
Set<BlockPos> stale = new HashSet<>(previous.positions());
stale.removeAll(target);
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
placed.add(pos);
}
}
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
player.resetFallDistance();
}
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
}
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
CarpetState state = ACTIVE_CARPETS.remove(owner);
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state == null) return;
removePositions(server, owner, state, state.positions());
ServerPlayer player = server.getPlayerList().getPlayer(owner);
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
}
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
ServerLevel level = server.getLevel(state.dimension());
if (level == null) return;
for (BlockPos pos : positions) {
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
}
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
}
private static void reset(MinecraftServer server) {
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
ACTIVE_CARPETS.clear();
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
}
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
}
}
@@ -0,0 +1,56 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
public final class MagicCarpetLedger {
private final Map<UUID, State> carpets = new HashMap<>();
public boolean contains(UUID owner) {
return carpets.containsKey(owner);
}
public State state(UUID owner, ResourceKey<Level> dimension) {
return carpets.getOrDefault(owner, State.empty(dimension));
}
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
}
public State remove(UUID owner) {
return carpets.remove(owner);
}
public Set<UUID> owners() {
return Set.copyOf(carpets.keySet());
}
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension)
&& entry.getValue().positions().contains(pos));
}
public Map<UUID, State> drain() {
Map<UUID, State> copy = Map.copyOf(carpets);
carpets.clear();
return copy;
}
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
public State {
positions = Set.copyOf(positions);
}
private static State empty(ResourceKey<Level> dimension) {
return new State(dimension, Set.of());
}
}
}
@@ -0,0 +1,39 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
public final class MagicCarpetMigration {
private MagicCarpetMigration() {
}
public static boolean isAnyCarpet(ItemStack stack) {
return stack != null && !stack.isEmpty()
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
}
public static ItemStack migrateStack(ItemStack stack) {
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
return migrated;
}
public static int migrateContainer(Container container) {
if (container == null) return 0;
int migrated = 0;
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack current = container.getItem(slot);
ItemStack replacement = migrateStack(current);
if (replacement == current) continue;
container.setItem(slot, replacement);
migrated++;
}
if (migrated > 0) container.setChanged();
return migrated;
}
}
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Converts online inventories once their historical alias has been decoded. */
public final class MagicCarpetMigrationService {
private MagicCarpetMigrationService() {
}
public static void register() {
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
}
private static void tick(MinecraftServer server) {
ILikeToMoveItData.get(server);
if (server.overworld().getGameTime() % 20L != 0L) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
}
private static void migrate(ServerPlayer player) {
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
if (migrated > 0) {
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
migrated, player.getGameProfile().name());
}
}
}
@@ -0,0 +1,124 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.List;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.FireworkExplosion;
import net.minecraft.world.item.component.Fireworks;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
public final class NotchCarpetRewardService {
private static final int REWARD_SLOT = 13;
private NotchCarpetRewardService() {
}
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
}
private static void tick(MinecraftServer server) {
if (server.overworld().getGameTime() % 20L != 0L) return;
var state = SanctuaryNotchMilestone.snapshot(server);
if (!state.defeated() || state.initialRewardDelivered()) return;
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
if (alpha == null) return;
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
if (!alpha.isLoaded(home)) return;
RewardChest result = ensureRewardChest(alpha, home);
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
if (result.created()) {
launchFirework(alpha, result.pos());
server.getPlayerList().broadcastSystemMessage(
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
}
ILikeToMoveItMod.LOGGER.info(
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
result.pos().toShortString(), !result.created());
}
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
List<BlockPos> candidates = candidates(home);
for (BlockPos pos : candidates) {
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
}
for (BlockPos pos : candidates) {
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|| level.getBlockState(pos.below()).isAir()) continue;
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
chest.setItem(REWARD_SLOT, rewardStack());
chest.setChanged();
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
return new RewardChest(pos, true);
}
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
return null;
}
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
for (ItemStack stack : container) {
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
}
return false;
}
private static ItemStack rewardStack() {
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
reward.set(DataComponents.CUSTOM_NAME,
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
return reward;
}
private static List<BlockPos> candidates(BlockPos home) {
ArrayList<BlockPos> positions = new ArrayList<>();
for (int y : new int[] {0, 1, -1, 2}) {
for (int radius = 2; radius <= 7; radius++) {
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
BlockPos candidate = home.offset(x, y, z).immutable();
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
}
}
}
return List.copyOf(positions);
}
private static void launchFirework(ServerLevel level, BlockPos pos) {
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
new IntArrayList(new int[] {0x9B59B6}), true, true);
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
}
private record RewardChest(BlockPos pos, boolean created) {
}
}
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Aircraft",
"item.iliketomoveit.magic_cloud": "Magic Cloud",
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
"item.iliketomoveit.magic_carpet": "Magic Carpet",
"item.portals.notch_totem": "Notch Totem (legacy)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
"block.iliketomoveit.waystone": "Waystone",
"block.waystone.waystone": "Waystone (legacy)",
"entity.iliketomoveit.aircraft": "Aircraft",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
"screen.iliketomoveit.waystone.title": "Waystones",
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Avion",
"item.iliketomoveit.magic_cloud": "Nuage magique",
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
"item.iliketomoveit.magic_carpet": "Tapis magique",
"item.portals.notch_totem": "Totem de Notch (hérité)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
"block.iliketomoveit.waystone": "Pierre de voyage",
"block.waystone.waystone": "Pierre de voyage (héritée)",
"entity.iliketomoveit.aircraft": "Avion",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Самолёт",
"item.iliketomoveit.magic_cloud": "Волшебное облако",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
"item.portals.notch_totem": "Тотем Нотча (старый)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
"block.iliketomoveit.waystone": "Камень пути",
"block.waystone.waystone": "Камень пути (старый)",
"entity.iliketomoveit.aircraft": "Самолёт",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
"screen.iliketomoveit.waystone.title": "Камни пути",
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "iliketomoveit:item/magic_carpet"
}
}
@@ -33,13 +33,13 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"anotherworld": ">=0.0.0-alpha.46",
"sanctuary": ">=0.0.0-alpha.21"
"sanctuary": ">=0.0.0-alpha.127"
},
"custom": {
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "iliketomoveit",
"data_version": 3,
"data_version": 4,
"network_protocol": 5,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -3,7 +3,9 @@
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin"
"HappyGhastMixin",
"MagicCarpetContainerMixin",
"MagicCarpetItemEntityMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,45 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Regression checks for overlap, world changes and shutdown cleanup ownership. */
public final class MagicCarpetLedgerSmoke {
private MagicCarpetLedgerSmoke() {
}
public static void main(String[] args) {
MagicCarpetLedger ledger = new MagicCarpetLedger();
UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
ResourceKey<Level> firstWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "first"));
ResourceKey<Level> secondWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "second"));
BlockPos shared = new BlockPos(4, 70, 4);
ledger.track(first, firstWorld, Set.of(shared));
ledger.track(second, firstWorld, Set.of(shared));
if (!ledger.trackedByAnother(first, firstWorld, shared)) {
throw new AssertionError("Overlapping carpets lost their second owner");
}
ledger.remove(first);
if (ledger.trackedByAnother(second, firstWorld, shared)) {
throw new AssertionError("Removed owner still controls the shared glass");
}
ledger.track(second, secondWorld, Set.of(new BlockPos(0, 64, 0)));
if (!ledger.state(second, secondWorld).dimension().equals(secondWorld)
|| !ledger.state(second, firstWorld).dimension().equals(secondWorld)) {
throw new AssertionError("Dimension change did not replace the tracked world");
}
Map<UUID, MagicCarpetLedger.State> shutdown = ledger.drain();
if (shutdown.size() != 1 || !ledger.owners().isEmpty()) {
throw new AssertionError("Server shutdown did not atomically drain temporary platforms");
}
}
}
@@ -48,7 +48,7 @@ public final class OnlyFunMod implements ModInitializer {
OnlyFunCommands.register();
AbjectionActions.initialize();
AbjectionCommands.register();
LOGGER.info("[{}] Loot generator, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
LOGGER.info("[{}] Loot generator, boss-gated Shop, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, OnlyFunNetworking.PROTOCOL_VERSION);
}
}
@@ -1,29 +1,22 @@
package fr.koka99cab.sanctuary26.onlyfun.shop;
import java.util.Objects;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.progression.SanctuaryWorldMilestones;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Keeps exceptional boss rewards out of the Shop until their future boss owner unlocks them. */
/** Keeps exceptional boss rewards out of the Shop until their public world milestone is reached. */
public final class OnlyFunBossShopGate {
public static final Identifier MAGIC_CLOUD = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_cloud");
public static final Identifier MAGIC_CARPET = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_carpet");
private static final Set<Identifier> BOSS_REWARDS = Set.of(MAGIC_CLOUD, MAGIC_CARPET);
private static final BossUnlockResolver LOCKED = (server, reward) -> false;
private static volatile BossUnlockResolver resolver = LOCKED;
private static final Map<Identifier, Identifier> IMPLEMENTED_MILESTONES = Map.of(
MAGIC_CARPET, SanctuaryNotchMilestone.NOTCH_DEFEATED);
private OnlyFunBossShopGate() {}
/** Called later by the module that owns the bosses and their persistent defeat state. */
public static synchronized void registerUnlockResolver(BossUnlockResolver value) {
Objects.requireNonNull(value, "value");
if (resolver != LOCKED && resolver != value) {
throw new IllegalStateException("A boss Shop unlock resolver is already registered");
}
resolver = value;
}
public static boolean requiresBoss(String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id != null && BOSS_REWARDS.contains(id);
@@ -31,11 +24,13 @@ public final class OnlyFunBossShopGate {
public static boolean isAvailable(MinecraftServer server, String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id == null || !BOSS_REWARDS.contains(id) || resolver.isUnlocked(server, id);
if (id == null || !BOSS_REWARDS.contains(id)) return true;
Identifier milestone = requiredMilestone(id);
return milestone != null && SanctuaryWorldMilestones.isReached(server, milestone);
}
@FunctionalInterface
public interface BossUnlockResolver {
boolean isUnlocked(MinecraftServer server, Identifier rewardItem);
/** Returns null while the corresponding boss contract is not implemented. */
public static Identifier requiredMilestone(Identifier rewardItem) {
return IMPLEMENTED_MILESTONES.get(rewardItem);
}
}
+2 -2
View File
@@ -26,8 +26,8 @@
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"iliketomoveit": "*",
"sanctuary": "*"
"iliketomoveit": ">=0.0.0-alpha.17",
"sanctuary": ">=0.0.0-alpha.127"
},
"custom": {
"sanctuary26": {
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.onlyfun.shop;
import fr.koka99cab.sanctuary26.onlyfun.policy.OnlyFunItemPolicy;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOfferCatalog;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -94,12 +95,12 @@ public final class OnlyFunShopCatalogSmoke {
"Magic Cloud must remain boss-gated");
require(!OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Boss rewards must be unavailable while their bosses do not exist");
OnlyFunBossShopGate.registerUnlockResolver((server, reward) ->
reward.equals(OnlyFunBossShopGate.MAGIC_CARPET));
require(OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Each future boss must unlock only its corresponding reward");
"Boss rewards must reject lookups without authoritative server state");
require(SanctuaryNotchMilestone.NOTCH_DEFEATED.equals(
OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CARPET)),
"Magic Carpet must resolve the public Notch milestone");
require(OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CLOUD) == null,
"Magic Cloud must stay locked until its own boss is implemented");
for (String protectedId : List.of(
"anotherworld:ruby", "anotherworld:ruby_block", "anotherworld:deepslate_ruby_ore",
@@ -0,0 +1,77 @@
{
"schema_version": 1,
"status": "unreleased",
"source": {
"pack_version": "26.2.0-alpha.223",
"modules": {
"iliketomoveit": "0.0.0-alpha.16",
"onlyfun": "0.0.0-alpha.25",
"sanctuary": "0.0.0-alpha.126"
}
},
"target": {
"pack_version": "26.2.0-alpha.223",
"modules": {
"iliketomoveit": "0.0.0-alpha.17",
"onlyfun": "0.0.0-alpha.26",
"sanctuary": "0.0.0-alpha.127"
}
},
"magic_carpet": {
"owner": "iliketomoveit",
"canonical_item": "iliketomoveit:magic_carpet",
"legacy_item": "portals:notch_totem",
"legacy_alias_hidden": true,
"legacy_components_preserved": true,
"legacy_custom_name_preserved": true,
"legacy_locations": ["player_inventory", "container", "item_entity"],
"one_legacy_stack_becomes_one_canonical_stack": true,
"new_worlds_distribute_legacy_item": false,
"temporary_platform": {
"size": [3, 1, 3],
"block": "minecraft:glass",
"air_only_placement": true,
"creative_flight_granted": false,
"overlap_owner_tracking": true,
"cleanup": ["deactivation", "disconnect", "dimension_change", "server_stop"]
}
},
"notch_reward": {
"milestone_owner": "sanctuary",
"milestone": "sanctuary:notch_defeated",
"reservation": "initial_reward_claimed",
"completion": "initial_reward_delivered",
"delivery_owner": "iliketomoveit",
"delivery": "deterministic_chest_near_notch_residence",
"firework_signal": true,
"idempotent_recovery": true,
"maximum_canonical_initial_rewards": 1,
"provenance": "sanctuary:notch_reward"
},
"shop_replacement": {
"catalog_owner": "onlyfun",
"transaction_owner": "sanctuary",
"unlock_resolver": "SanctuaryWorldMilestones",
"hidden_before_notch": true,
"server_authoritative_gate": true,
"configured_ruby_price": 1536,
"configured_sapphire_price": 128,
"daily_rotation_rules_preserved": true,
"mailbox_delivery": true,
"recreates_initial_reward": false,
"provenance": "sanctuary:shop_purchase",
"replacement_logged": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"pack_release_updated": false,
"iliketomoveit_data_version": 4,
"iliketomoveit_network_protocol": 5,
"onlyfun_data_version": 2,
"onlyfun_network_protocol": 3,
"sanctuary_data_version": 13,
"sanctuary_notch_state_data_version": 2,
"sanctuary_network_protocol": 22
}
}
+12 -2
View File
@@ -49,5 +49,15 @@ Ouch possède les attaques et les dégâts ; seule l'explosion de transformation
peut casser des blocs. La victoire est enregistrée une seule fois dans les
données globales Sanctuary, même après redémarrage ou restauration de l'entité.
Les participants reçoivent le jalon `sanctuary:notch_defeated` et un marqueur
de boss dans leur Atlas si Alpha y était déjà découverte. Le drapeau de
récompense initiale reste séparé et monotone pour la future étape CARPET-01.
de boss dans leur Atlas si Alpha y était déjà découverte. Sanctuary réserve
ensuite une récompense mondiale unique : I Like To Move It place le
`iliketomoveit:magic_carpet` dans un coffre près de la maison et le signale par
un feu d'artifice. La réservation et la livraison sont persistées séparément
afin qu'une reprise serveur termine le coffre sans créer un second tapis.
L'ancien `portals:notch_totem` reste lisible et devient un Magic Carpet en
conservant ses composants et son nom. Après la victoire, Only Fun autorise
l'offre exceptionnelle dans la rotation normale du Shop ; les remplacements
sont payés et livrés par la Mailbox sans recréer la récompense initiale. Le
contrat détaillé est dans
[`iliketomoveit/MAGIC_CARPET.md`](../iliketomoveit/MAGIC_CARPET.md).
+13 -13
View File
@@ -175,7 +175,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| !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")
@@ -1184,13 +1184,13 @@ tasks.register("verifySanctuary") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 12
if (manifest.custom?.sanctuary26?.data_version != 13
|| manifest.custom?.sanctuary26?.network_protocol != 22
|| 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 v11, protocol 22, JEI and the client entrypoint")
throw new GradleException("Sanctuary must expose data v13, protocol 22, 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"))
@@ -2891,7 +2891,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| 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
@@ -3057,7 +3057,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| 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
@@ -3095,7 +3095,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| 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]
@@ -3126,7 +3126,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3157,7 +3157,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3185,7 +3185,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3231,7 +3231,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3289,7 +3289,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| 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"
@@ -3358,7 +3358,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| 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"
@@ -3430,7 +3430,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.126"
if (project.version.toString() != "0.0.0-alpha.127"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -34,7 +34,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 = 12;
public static final int CURRENT_DATA_VERSION = 13;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public SanctuaryMod() {
@@ -0,0 +1,42 @@
package fr.koka99cab.sanctuary26.sanctuary.api.item;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import net.minecraft.core.component.DataComponents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.CustomData;
/** Stable item metadata used to distinguish boss rewards, migrations and Shop replacements. */
public final class SanctuaryItemProvenance {
public static final Identifier NOTCH_REWARD = SanctuaryMod.id("notch_reward");
public static final Identifier SHOP_PURCHASE = SanctuaryMod.id("shop_purchase");
public static final Identifier LEGACY_NOTCH_TOTEM = SanctuaryMod.id("legacy_notch_totem");
private static final String ORIGIN_KEY = "sanctuary_origin";
private static final String VERSION_KEY = "sanctuary_origin_version";
private SanctuaryItemProvenance() {
}
public static void stamp(ItemStack stack, Identifier origin) {
if (stack == null || stack.isEmpty() || origin == null) return;
CustomData.update(DataComponents.CUSTOM_DATA, stack, tag -> {
tag.putString(ORIGIN_KEY, origin.toString());
tag.putInt(VERSION_KEY, 1);
});
}
public static void stampIfAbsent(ItemStack stack, Identifier origin) {
if (origin(stack) == null) stamp(stack, origin);
}
public static Identifier origin(ItemStack stack) {
if (stack == null || stack.isEmpty()) return null;
CustomData data = stack.get(DataComponents.CUSTOM_DATA);
if (data == null) return null;
return Identifier.tryParse(data.copyTag().getStringOr(ORIGIN_KEY, ""));
}
public static boolean hasOrigin(ItemStack stack, Identifier origin) {
return origin != null && origin.equals(origin(stack));
}
}
@@ -57,6 +57,11 @@ public final class SanctuaryNotchMilestone {
return NotchVictoryData.get(server).claimInitialReward();
}
/** Completes the reservation only after the physical reward chest exists. */
public static boolean completeInitialReward(MinecraftServer server) {
return NotchVictoryData.get(server).completeInitialReward();
}
private static VictoryResult record(ServerLevel alphaLevel, List<UUID> participants,
boolean rewardAlreadyClaimed, boolean announce) {
NotchVictoryData data = NotchVictoryData.get(alphaLevel.getServer());
@@ -73,7 +78,8 @@ public final class SanctuaryNotchMilestone {
alphaLevel.getServer().getPlayerList().broadcastSystemMessage(
Component.translatable("message.sanctuary.notch_defeated").withStyle(ChatFormatting.GOLD), false);
}
return new VictoryResult(newlyDefeated, state.participants(), state.initialRewardClaimed());
return new VictoryResult(newlyDefeated, state.participants(), state.initialRewardClaimed(),
state.initialRewardDelivered());
}
private static List<UUID> sanitizeParticipants(ServerLevel level, Collection<UUID> candidates) {
@@ -91,7 +97,7 @@ public final class SanctuaryNotchMilestone {
}
public record VictoryResult(boolean newlyDefeated, List<UUID> participants,
boolean initialRewardClaimed) {
boolean initialRewardClaimed, boolean initialRewardDelivered) {
public VictoryResult {
participants = List.copyOf(participants);
}
@@ -0,0 +1,17 @@
package fr.koka99cab.sanctuary26.sanctuary.api.progression;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Public, server-authoritative resolver for world-wide progression milestones. */
public final class SanctuaryWorldMilestones {
private SanctuaryWorldMilestones() {
}
public static boolean isReached(MinecraftServer server, Identifier milestone) {
if (server == null || milestone == null) return false;
return milestone.equals(SanctuaryNotchMilestone.NOTCH_DEFEATED)
&& SanctuaryNotchMilestone.isDefeated(server);
}
}
@@ -10,7 +10,7 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** SavedData wrapper for the world-wide Notch victory and future unique reward claim. */
/** SavedData wrapper for the world-wide Notch victory and two-phase unique reward delivery. */
public final class NotchVictoryData extends SavedData {
private static final SavedDataType<NotchVictoryData> TYPE = new SavedDataType<>(
SanctuaryMod.id("notch_victory"),
@@ -57,4 +57,12 @@ public final class NotchVictoryData extends SavedData {
setDirty();
return true;
}
public boolean completeInitialReward() {
NotchVictoryState updated = state.completeInitialReward();
if (updated.equals(state)) return false;
state = updated;
setDirty();
return true;
}
}
@@ -13,34 +13,42 @@ public record NotchVictoryState(
boolean defeated,
long defeatedAt,
List<String> participantIds,
boolean initialRewardClaimed
boolean initialRewardClaimed,
boolean initialRewardDelivered
) {
public static final int DATA_VERSION = 1;
public static final int DATA_VERSION = 2;
public static final int MAX_PARTICIPANTS = 16;
public static final Codec<NotchVictoryState> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(NotchVictoryState::dataVersion),
Codec.INT.optionalFieldOf("data_version", 1).forGetter(NotchVictoryState::dataVersion),
Codec.BOOL.optionalFieldOf("notch_defeated", false).forGetter(NotchVictoryState::defeated),
Codec.LONG.optionalFieldOf("defeated_at", 0L).forGetter(NotchVictoryState::defeatedAt),
Codec.STRING.listOf().optionalFieldOf("participants", List.of()).forGetter(NotchVictoryState::participantIds),
Codec.BOOL.optionalFieldOf("initial_reward_claimed", false)
.forGetter(NotchVictoryState::initialRewardClaimed)
.forGetter(NotchVictoryState::initialRewardClaimed),
Codec.BOOL.optionalFieldOf("initial_reward_delivered", false)
.forGetter(NotchVictoryState::initialRewardDelivered)
).apply(instance, NotchVictoryState::new));
public NotchVictoryState {
int storedVersion = dataVersion;
dataVersion = DATA_VERSION;
defeatedAt = Math.max(0L, defeatedAt);
participantIds = normalizeStrings(participantIds);
initialRewardClaimed = defeated && initialRewardClaimed;
// Version 1 only knew the claimed flag. Treat those historical claims as
// already delivered so migrated worlds never receive a duplicate carpet.
initialRewardDelivered = initialRewardClaimed
&& (initialRewardDelivered || storedVersion < DATA_VERSION);
}
public static NotchVictoryState empty() {
return new NotchVictoryState(DATA_VERSION, false, 0L, List.of(), false);
return new NotchVictoryState(DATA_VERSION, false, 0L, List.of(), false, false);
}
public NotchVictoryState defeat(Collection<UUID> participants, long gameTime, boolean rewardAlreadyClaimed) {
if (defeated) {
return rewardAlreadyClaimed && !initialRewardClaimed
? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true)
return rewardAlreadyClaimed && !initialRewardDelivered
? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true, true)
: this;
}
List<String> ids = participants == null ? List.of() : participants.stream()
@@ -48,12 +56,20 @@ public record NotchVictoryState(
.map(UUID::toString)
.toList();
return new NotchVictoryState(DATA_VERSION, true, Math.max(0L, gameTime), ids,
rewardAlreadyClaimed);
rewardAlreadyClaimed, rewardAlreadyClaimed);
}
/** Reserves the unique reward before the physical chest is created. */
public NotchVictoryState claimInitialReward() {
return defeated && !initialRewardClaimed
? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true)
? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true, false)
: this;
}
/** Marks the reserved reward delivered after its idempotent chest exists. */
public NotchVictoryState completeInitialReward() {
return defeated && initialRewardClaimed && !initialRewardDelivered
? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true, true)
: this;
}
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import fr.koka99cab.sanctuary26.sanctuary.network.ShopActionPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.ShopSnapshotPayload;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService;
@@ -29,6 +30,7 @@ public final class ShopService {
public static final boolean OPEN = true;
private static final Identifier RUBY_ID = Identifier.fromNamespaceAndPath("anotherworld", "ruby");
private static final Identifier SAPPHIRE_ID = Identifier.fromNamespaceAndPath("anotherworld", "sapphire");
private static final String MAGIC_CARPET_ID = "iliketomoveit:magic_carpet";
private static final int DELIVERY_RADIUS = 256;
static final int MINIMUM_DELIVERY_TICKS = 20 * 20;
static final int MAXIMUM_DELIVERY_TICKS = 5 * 60 * 20;
@@ -118,6 +120,11 @@ public final class ShopService {
delaySeconds / 60, delaySeconds % 60));
SanctuaryMod.LOGGER.info("[Shop] Scheduled {} x{} for {} in {} ticks.", itemId, parcel.getCount(),
player.getGameProfile().name(), delayTicks);
if (MAGIC_CARPET_ID.equals(itemId)) {
SanctuaryMod.LOGGER.info(
"[Shop] Logged Magic Carpet replacement for {} (origin=shop_purchase, reward_recreated=false).",
player.getGameProfile().name());
}
return "purchase_scheduled";
}
@@ -141,6 +148,9 @@ public final class ShopService {
Optional<ShopData.DeliveryDestination> destination = data.loadedDeliveryBox(server, pending.playerId());
if (destination.isEmpty()) continue;
ItemStack parcel = new ItemStack(item, pending.count());
if (MAGIC_CARPET_ID.equals(pending.item())) {
SanctuaryItemProvenance.stamp(parcel, SanctuaryItemProvenance.SHOP_PURCHASE);
}
DeliveryBoxBlockEntity box = destination.get().box();
if (!box.insertDelivery(parcel)) continue;
data.completeDelivery(pending);
+1 -1
View File
@@ -32,7 +32,7 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "sanctuary",
"data_version": 12,
"data_version": 13,
"network_protocol": 22,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.sanctuary.data;
import com.google.gson.JsonObject;
import com.mojang.serialization.JsonOps;
import java.util.ArrayList;
import java.util.List;
@@ -16,25 +17,39 @@ public final class NotchVictoryStateSmoke {
List.of(first, second, first), 1234L, false);
if (!defeated.defeated() || defeated.defeatedAt() != 1234L
|| !defeated.participants().equals(List.of(first, second))
|| defeated.initialRewardClaimed()) {
|| defeated.initialRewardClaimed() || defeated.initialRewardDelivered()) {
throw new AssertionError("First Notch victory was not normalized");
}
NotchVictoryState replayed = defeated.defeat(List.of(UUID.randomUUID()), 9999L, false);
if (replayed != defeated) throw new AssertionError("World victory is not monotone");
NotchVictoryState claimed = defeated.claimInitialReward();
if (!claimed.initialRewardClaimed() || claimed.claimInitialReward() != claimed) {
if (!claimed.initialRewardClaimed() || claimed.initialRewardDelivered()
|| claimed.claimInitialReward() != claimed) {
throw new AssertionError("Unique reward claim is not monotone");
}
NotchVictoryState delivered = claimed.completeInitialReward();
if (!delivered.initialRewardDelivered() || delivered.completeInitialReward() != delivered) {
throw new AssertionError("Unique reward delivery is not recoverable and monotone");
}
ArrayList<String> malformed = new ArrayList<>();
malformed.add("invalid");
for (int index = 0; index < 40; index++) malformed.add(new UUID(0L, index + 10L).toString());
NotchVictoryState bounded = new NotchVictoryState(0, true, -5L, malformed, true);
NotchVictoryState bounded = new NotchVictoryState(1, true, -5L, malformed, true, false);
if (bounded.dataVersion() != NotchVictoryState.DATA_VERSION || bounded.defeatedAt() != 0L
|| bounded.participantIds().size() != NotchVictoryState.MAX_PARTICIPANTS) {
|| bounded.participantIds().size() != NotchVictoryState.MAX_PARTICIPANTS
|| !bounded.initialRewardDelivered()) {
throw new AssertionError("Legacy Notch victory normalization is unbounded");
}
var encoded = NotchVictoryState.CODEC.encodeStart(JsonOps.INSTANCE, claimed).getOrThrow();
JsonObject implicitV1 = new JsonObject();
implicitV1.addProperty("notch_defeated", true);
implicitV1.addProperty("initial_reward_claimed", true);
NotchVictoryState implicitLegacyClaim = NotchVictoryState.CODEC
.parse(JsonOps.INSTANCE, implicitV1).getOrThrow();
if (!implicitLegacyClaim.initialRewardDelivered()) {
throw new AssertionError("An omitted v1 data_version duplicated the historical reward");
}
var encoded = NotchVictoryState.CODEC.encodeStart(JsonOps.INSTANCE, delivered).getOrThrow();
NotchVictoryState decoded = NotchVictoryState.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow();
if (!decoded.equals(claimed)) throw new AssertionError("Notch victory codec did not round-trip");
if (!decoded.equals(delivered)) throw new AssertionError("Notch victory codec did not round-trip");
}
}