From 106d730dd8ddce8a760c1fcb0007cb8bda2b8c19 Mon Sep 17 00:00:00 2001 From: koka Date: Mon, 31 Aug 2026 06:45:28 +0200 Subject: [PATCH] feat: deliver and migrate the Magic Carpet --- CHANGELOG.md | 10 +- README.md | 2 +- build.gradle | 194 ++++++++++++++---- gradle.properties | 6 +- iliketomoveit/MAGIC_CARPET.md | 66 ++++++ iliketomoveit/build.gradle | 29 ++- .../iliketomoveit/ILikeToMoveItMod.java | 6 +- .../iliketomoveit/item/MagicCarpetItem.java | 41 ++++ .../mixin/MagicCarpetContainerMixin.java | 19 ++ .../mixin/MagicCarpetItemEntityMixin.java | 21 ++ .../registry/ILikeToMoveItItems.java | 7 +- .../transport/MagicCarpetController.java | 41 ++-- .../transport/MagicCarpetLedger.java | 56 +++++ .../transport/MagicCarpetMigration.java | 39 ++++ .../MagicCarpetMigrationService.java | 34 +++ .../transport/NotchCarpetRewardService.java | 124 +++++++++++ .../assets/iliketomoveit/lang/en_us.json | 9 +- .../assets/iliketomoveit/lang/fr_fr.json | 9 +- .../assets/iliketomoveit/lang/ru_ru.json | 9 +- .../assets/portals/items/notch_totem.json | 6 + .../src/main/resources/fabric.mod.json | 4 +- .../main/resources/iliketomoveit.mixins.json | 4 +- .../transport/MagicCarpetLedgerSmoke.java | 45 ++++ .../sanctuary26/onlyfun/OnlyFunMod.java | 2 +- .../onlyfun/shop/OnlyFunBossShopGate.java | 29 ++- onlyfun/src/main/resources/fabric.mod.json | 4 +- .../onlyfun/shop/OnlyFunShopCatalogSmoke.java | 13 +- ...2.0-alpha.223-magic-carpet-unreleased.json | 77 +++++++ sanctuary/ALPHA.md | 14 +- sanctuary/build.gradle | 26 +-- .../sanctuary26/sanctuary/SanctuaryMod.java | 2 +- .../api/item/SanctuaryItemProvenance.java | 42 ++++ .../api/notch/SanctuaryNotchMilestone.java | 10 +- .../progression/SanctuaryWorldMilestones.java | 17 ++ .../sanctuary/data/NotchVictoryData.java | 10 +- .../sanctuary/data/NotchVictoryState.java | 34 ++- .../sanctuary/shop/ShopService.java | 10 + sanctuary/src/main/resources/fabric.mod.json | 2 +- .../data/NotchVictoryStateSmoke.java | 27 ++- 39 files changed, 949 insertions(+), 151 deletions(-) create mode 100644 iliketomoveit/MAGIC_CARPET.md create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/item/MagicCarpetItem.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetContainerMixin.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetItemEntityMixin.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedger.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigration.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigrationService.java create mode 100644 iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/NotchCarpetRewardService.java create mode 100644 iliketomoveit/src/main/resources/assets/portals/items/notch_totem.json create mode 100644 iliketomoveit/src/test/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedgerSmoke.java create mode 100644 pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/item/SanctuaryItemProvenance.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/progression/SanctuaryWorldMilestones.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa0291..fac1a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index 7d8e099..a5d6303 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ L’alpha.223 ajoute le tri natif des rangées de stockage par clic molette sans L’alpha.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 d’IDs d’items 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. L’alpha.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. diff --git a/build.gradle b/build.gradle index cbbd79c..ddcc292 100644 --- a/build.gradle +++ b/build.gradle @@ -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") diff --git a/gradle.properties b/gradle.properties index ed5004b..9e4e909 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/iliketomoveit/MAGIC_CARPET.md b/iliketomoveit/MAGIC_CARPET.md new file mode 100644 index 0000000..4e4acd2 --- /dev/null +++ b/iliketomoveit/MAGIC_CARPET.md @@ -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. diff --git a/iliketomoveit/build.gradle b/iliketomoveit/build.gradle index 6f68977..f9edd7d 100644 --- a/iliketomoveit/build.gradle +++ b/iliketomoveit/build.gradle @@ -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") diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/ILikeToMoveItMod.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/ILikeToMoveItMod.java index ef8679e..a23c04f 100644 --- a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/ILikeToMoveItMod.java +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/ILikeToMoveItMod.java @@ -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); diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/item/MagicCarpetItem.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/item/MagicCarpetItem.java new file mode 100644 index 0000000..ebee114 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/item/MagicCarpetItem.java @@ -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 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); + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetContainerMixin.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetContainerMixin.java new file mode 100644 index 0000000..245dcf3 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetContainerMixin.java @@ -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); + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetItemEntityMixin.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetItemEntityMixin.java new file mode 100644 index 0000000..49b7ad5 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/mixin/MagicCarpetItemEntityMixin.java @@ -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); + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/registry/ILikeToMoveItItems.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/registry/ILikeToMoveItItems.java index e727b85..5d20e4e 100644 --- a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/registry/ILikeToMoveItItems.java +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/registry/ILikeToMoveItItems.java @@ -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", diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetController.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetController.java index f5b4826..bccbbc5 100644 --- a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetController.java +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetController.java @@ -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 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> iterator = ACTIVE_CARPETS.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry 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 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 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 positions) { + private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set 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 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 copy = new HashMap<>(ACTIVE_CARPETS); - ACTIVE_CARPETS.clear(); + Map 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 dimension, Set positions) { - private static CarpetState empty(ResourceKey dimension) { return new CarpetState(dimension, Set.of()); } - } } diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedger.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedger.java new file mode 100644 index 0000000..eadd0f8 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedger.java @@ -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 carpets = new HashMap<>(); + + public boolean contains(UUID owner) { + return carpets.containsKey(owner); + } + + public State state(UUID owner, ResourceKey dimension) { + return carpets.getOrDefault(owner, State.empty(dimension)); + } + + public void track(UUID owner, ResourceKey dimension, Set positions) { + carpets.put(owner, new State(dimension, Set.copyOf(positions))); + } + + public State remove(UUID owner) { + return carpets.remove(owner); + } + + public Set owners() { + return Set.copyOf(carpets.keySet()); + } + + public boolean trackedByAnother(UUID owner, ResourceKey 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 drain() { + Map copy = Map.copyOf(carpets); + carpets.clear(); + return copy; + } + + public record State(ResourceKey dimension, Set positions) { + public State { + positions = Set.copyOf(positions); + } + + private static State empty(ResourceKey dimension) { + return new State(dimension, Set.of()); + } + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigration.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigration.java new file mode 100644 index 0000000..58ad0d5 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigration.java @@ -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; + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigrationService.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigrationService.java new file mode 100644 index 0000000..d9e358d --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetMigrationService.java @@ -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()); + } + } +} diff --git a/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/NotchCarpetRewardService.java b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/NotchCarpetRewardService.java new file mode 100644 index 0000000..ae1f2d1 --- /dev/null +++ b/iliketomoveit/src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/NotchCarpetRewardService.java @@ -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 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 candidates(BlockPos home) { + ArrayList 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) { + } +} diff --git a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/en_us.json b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/en_us.json index 6e4891c..7769a38 100644 --- a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/en_us.json +++ b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/en_us.json @@ -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", diff --git a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/fr_fr.json b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/fr_fr.json index 806b240..17d0637 100644 --- a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/fr_fr.json +++ b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/fr_fr.json @@ -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", diff --git a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/ru_ru.json b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/ru_ru.json index 8300a03..ffad088 100644 --- a/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/ru_ru.json +++ b/iliketomoveit/src/main/resources/assets/iliketomoveit/lang/ru_ru.json @@ -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": "В этом измерении нет других камней", diff --git a/iliketomoveit/src/main/resources/assets/portals/items/notch_totem.json b/iliketomoveit/src/main/resources/assets/portals/items/notch_totem.json new file mode 100644 index 0000000..ef76221 --- /dev/null +++ b/iliketomoveit/src/main/resources/assets/portals/items/notch_totem.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "iliketomoveit:item/magic_carpet" + } +} diff --git a/iliketomoveit/src/main/resources/fabric.mod.json b/iliketomoveit/src/main/resources/fabric.mod.json index 57c1cee..80d69d6 100644 --- a/iliketomoveit/src/main/resources/fabric.mod.json +++ b/iliketomoveit/src/main/resources/fabric.mod.json @@ -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}", diff --git a/iliketomoveit/src/main/resources/iliketomoveit.mixins.json b/iliketomoveit/src/main/resources/iliketomoveit.mixins.json index 3974ac0..12fb6b5 100644 --- a/iliketomoveit/src/main/resources/iliketomoveit.mixins.json +++ b/iliketomoveit/src/main/resources/iliketomoveit.mixins.json @@ -3,7 +3,9 @@ "package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ - "HappyGhastMixin" + "HappyGhastMixin", + "MagicCarpetContainerMixin", + "MagicCarpetItemEntityMixin" ], "injectors": { "defaultRequire": 1 diff --git a/iliketomoveit/src/test/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedgerSmoke.java b/iliketomoveit/src/test/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedgerSmoke.java new file mode 100644 index 0000000..edcec14 --- /dev/null +++ b/iliketomoveit/src/test/java/fr/koka99cab/sanctuary26/iliketomoveit/transport/MagicCarpetLedgerSmoke.java @@ -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 firstWorld = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath("test", "first")); + ResourceKey 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 shutdown = ledger.drain(); + if (shutdown.size() != 1 || !ledger.owners().isEmpty()) { + throw new AssertionError("Server shutdown did not atomically drain temporary platforms"); + } + } +} diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java index 5255e27..1f50d85 100644 --- a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java @@ -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); } } diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunBossShopGate.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunBossShopGate.java index 0c3ca9b..3249073 100644 --- a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunBossShopGate.java +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunBossShopGate.java @@ -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 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 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); } } diff --git a/onlyfun/src/main/resources/fabric.mod.json b/onlyfun/src/main/resources/fabric.mod.json index f3e3531..73a91c7 100644 --- a/onlyfun/src/main/resources/fabric.mod.json +++ b/onlyfun/src/main/resources/fabric.mod.json @@ -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": { diff --git a/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalogSmoke.java b/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalogSmoke.java index 7151584..0771125 100644 --- a/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalogSmoke.java +++ b/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalogSmoke.java @@ -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", diff --git a/pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json b/pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json new file mode 100644 index 0000000..2481fe9 --- /dev/null +++ b/pack/migrations/26.2.0-alpha.223-magic-carpet-unreleased.json @@ -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 + } +} diff --git a/sanctuary/ALPHA.md b/sanctuary/ALPHA.md index 11b368c..a536f99 100644 --- a/sanctuary/ALPHA.md +++ b/sanctuary/ALPHA.md @@ -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). diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index 9ea7ee7..04be405 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -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" diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java index 6842461..4f1b24b 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java @@ -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() { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/item/SanctuaryItemProvenance.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/item/SanctuaryItemProvenance.java new file mode 100644 index 0000000..c11438d --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/item/SanctuaryItemProvenance.java @@ -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)); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java index df0535d..5372133 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java @@ -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 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 sanitizeParticipants(ServerLevel level, Collection candidates) { @@ -91,7 +97,7 @@ public final class SanctuaryNotchMilestone { } public record VictoryResult(boolean newlyDefeated, List participants, - boolean initialRewardClaimed) { + boolean initialRewardClaimed, boolean initialRewardDelivered) { public VictoryResult { participants = List.copyOf(participants); } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/progression/SanctuaryWorldMilestones.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/progression/SanctuaryWorldMilestones.java new file mode 100644 index 0000000..76319cd --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/progression/SanctuaryWorldMilestones.java @@ -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); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java index ecb4851..06737c6 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java @@ -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 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; + } } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java index 82693ee..d6d81b5 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java @@ -13,34 +13,42 @@ public record NotchVictoryState( boolean defeated, long defeatedAt, List 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 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 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 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; } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java index e5bf85d..8057679 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java @@ -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 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); diff --git a/sanctuary/src/main/resources/fabric.mod.json b/sanctuary/src/main/resources/fabric.mod.json index 2e3ebf3..bad71e9 100644 --- a/sanctuary/src/main/resources/fabric.mod.json +++ b/sanctuary/src/main/resources/fabric.mod.json @@ -32,7 +32,7 @@ "sanctuary26": { "system_type": "gameplay-system", "namespace": "sanctuary", - "data_version": 12, + "data_version": 13, "network_protocol": 22, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java index cdd1755..73dfbe4 100644 --- a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java @@ -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 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"); } } -- 2.54.0