From 9e302e12ef6bdcc7a4c8f39919b2ca565b0e82cb Mon Sep 17 00:00:00 2001 From: koka Date: Thu, 27 Aug 2026 13:22:57 +0200 Subject: [PATCH] feat(anotherworld): add player safe vault raids --- CHANGELOG.md | 12 + README.md | 4 +- anotherworld/build.gradle | 139 ++++++++- .../anotherworld/AnotherWorldMod.java | 4 +- .../anotherworld/block/SafeVaultBlock.java | 117 ++++++++ .../block/entity/SafeVaultBlockEntity.java | 60 ++++ .../anotherworld/item/TitaniumDrillItem.java | 41 +++ .../registry/AnotherWorldBlockEntities.java | 8 +- .../registry/AnotherWorldBlocks.java | 12 + .../registry/AnotherWorldItems.java | 6 + .../anotherworld/vault/VaultData.java | 274 ++++++++++++++++++ .../vault/VaultDrillingService.java | 176 +++++++++++ .../anotherworld/vault/VaultRules.java | 25 ++ .../anotherworld/vault/VaultService.java | 78 +++++ .../anotherworld/vault/VaultStructure.java | 61 ++++ .../textures/block/safe_vault/bottom.png | Bin 0 -> 4819 bytes .../textures/block/safe_vault/front.png | Bin 0 -> 5091 bytes .../textures/block/safe_vault/side.png | Bin 0 -> 5048 bytes .../textures/block/safe_vault/top.png | Bin 0 -> 4819 bytes .../anotherworld/textures/item/safe_block.png | Bin 0 -> 496 bytes .../textures/item/titanium_drill.png | Bin 0 -> 446 bytes .../src/main/resources/fabric.mod.json | 5 +- .../anotherworld/vault/VaultRulesSmoke.java | 26 ++ build.gradle | 81 +++++- gradle.properties | 4 +- .../anotherworld-safe-vault-data-v2.json | 76 +++++ sanctuary/build.gradle | 16 +- .../inventory/SanctuaryCurrencyStorage.java | 61 ++++ .../sanctuary/shop/BlackMarketService.java | 4 +- 29 files changed, 1252 insertions(+), 38 deletions(-) create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/SafeVaultBlock.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/entity/SafeVaultBlockEntity.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumDrillItem.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultData.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultDrillingService.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRules.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultService.java create mode 100644 anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultStructure.java create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/bottom.png create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/front.png create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/side.png create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/top.png create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/item/safe_block.png create mode 100644 anotherworld/src/main/resources/assets/anotherworld/textures/item/titanium_drill.png create mode 100644 anotherworld/src/test/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRulesSmoke.java create mode 100644 pack/migrations/anotherworld-safe-vault-data-v2.json create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6f570..08418a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve - Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46. - Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées. +## En développement — coffre-fort 3 × 3 + +Modules modifiés : **Another World `0.0.0-alpha.50`** et **Sanctuary `0.0.0-alpha.117`**. Le pack distribué reste en `26.2.0-alpha.201` jusqu’à la préparation d’une release dédiée. + +- ajoute `anotherworld:safe_block`, crafté avec huit blocs d’or autour d’un coffre, et active un unique coffre-fort par joueur lorsque neuf blocs du même propriétaire forment un carré horizontal exact de 3 × 3 ; +- conserve 27 emplacements globaux pour rubis, saphirs et émeraudes sous la clé versionnée `anotherworld_safe_vaults`, ce qui garde le solde accessible au Shop lorsque le chunk de la structure est déchargé ; +- expose dans Sanctuary une API publique de fournisseurs de monnaie et relie explicitement `Another World → Sanctuary` dans Gradle et Fabric, sans cycle ni copie des règles du Shop ; +- ajoute `anotherworld:titanium_drill` avec la texture 16×16 fournie et une recette en titane, redstone, piston, blocs de fer et pioche en diamant ; +- impose un forage serveur continu de 60 secondes sur un coffre ennemi dont le propriétaire est connecté, alerte celui-ci, consomme la foreuse en cas de réussite et transfère 20 % des gemmes au pillard ; +- protège le coffre pendant 24 heures après un pillage réussi, bloque les attaques concurrentes et interdit la casse par un tiers ou le démontage d’un coffre rempli ; +- porte les données Another World de v1 à v2 pour la nouvelle sauvegarde, sans changer les protocoles Another World 1 et Sanctuary 21 ni exiger de réinitialisation du monde. + ## `26.2.0-alpha.201` Module modifié : **Sanctuary `0.0.0-alpha.116`**. Les neuf autres modules conservent leur version issue de l’alpha.200. diff --git a/README.md b/README.md index 624662b..049ec2b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`) Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module. -Version courante du pack local : **`26.2.0-alpha.201`**. Sanctuary passe en **`0.0.0-alpha.116`** ; les neuf autres modules conservent leur version de l’alpha.200. +Version courante du pack distribué : **`26.2.0-alpha.201`**. Le développement du coffre-fort porte Another World en **`0.0.0-alpha.50`** et Sanctuary en **`0.0.0-alpha.117`** sans republier encore le pack. + +Le coffre-fort joueur utilise neuf blocs Another World disposés horizontalement en 3 × 3. Il accepte rubis, saphirs et émeraudes dans 27 emplacements liés au joueur et les rend disponibles aux transactions du Shop même lorsque la structure est déchargée. Une foreuse en titane maintenue pendant 60 secondes permet de piller 20 % du contenu d’un propriétaire connecté, qui reçoit immédiatement une alerte ; une réussite consomme la foreuse et protège ensuite le coffre pendant 24 heures. L’alpha.201 rend les demandes communautaires réellement terminables. Le demandeur dépose la récompense lors de la publication, le preneur confirme sans commande après avoir lu les détails, KILL progresse automatiquement, BRING livre les objets au demandeur et PAY est confirmé par le joueur désigné. La récompense séquestrée est ensuite expédiée une seule fois dans la Mailbox du preneur. Le pseudonyme conserve la couleur du joueur et le panneau rose adopte une palette plus claire et poudrée. diff --git a/anotherworld/build.gradle b/anotherworld/build.gradle index 8fd0a94..a0e1090 100644 --- a/anotherworld/build.gradle +++ b/anotherworld/build.gradle @@ -7,6 +7,18 @@ def generatedResources = layout.buildDirectory.dir("generated/resources") sourceSets.main.resources.srcDir(generatedResources) +tasks.register("vaultRulesSmoke", JavaExec) { + group = "verification" + description = "Checks the 3x3 vault, one-minute raid, 20% loot and 24-hour protection rules." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.anotherworld.vault.VaultRulesSmoke" +} + +tasks.named("test") { + failOnNoDiscoveredTests = false +} + tasks.register("generateAnotherWorldResources") { group = "build" description = "Generates the reproducible Another World 26.2 material catalog." @@ -165,6 +177,29 @@ tasks.register("generateAnotherWorldResources") { // Mirror, creative-only rainbow blocks, gems and first-pass metal art. simpleBlock("mirror", "minecraft:block/cube_all", "anotherworld:block/mirror_glass") + writeJson("assets/anotherworld/blockstates/safe_block.json", [variants: [ + "facing=north": [model: "anotherworld:block/safe_block"], + "facing=east": [model: "anotherworld:block/safe_block", y: 90], + "facing=south": [model: "anotherworld:block/safe_block", y: 180], + "facing=west": [model: "anotherworld:block/safe_block", y: 270] + ]]) + writeJson("assets/anotherworld/models/block/safe_block.json", [ + parent: "minecraft:block/cube", + textures: [ + particle: "anotherworld:block/safe_vault/front", + down: "anotherworld:block/safe_vault/bottom", + up: "anotherworld:block/safe_vault/top", + north: "anotherworld:block/safe_vault/front", + south: "anotherworld:block/safe_vault/side", + west: "anotherworld:block/safe_vault/side", + east: "anotherworld:block/safe_vault/side" + ] + ]) + writeJson("assets/anotherworld/models/item/safe_block.json", [ + parent: "minecraft:item/generated", textures: [layer0: "anotherworld:item/safe_block"] + ]) + writeJson("assets/anotherworld/items/safe_block.json", modelItem("anotherworld:item/safe_block")) + selfLoot("safe_block") simpleBlock("black_sand", "minecraft:block/cube_all", "anotherworld:block/black_sand") def rainbowColors = ["white", "light_gray", "gray", "black", "brown", "red", "orange", "yellow", "lime", "green", "cyan", "light_blue", "blue", "purple", "magenta", "pink"] rainbowColors.each { color -> @@ -215,6 +250,7 @@ tasks.register("generateAnotherWorldResources") { titanium_nugget: "anotherworld:item/titanium_nugget" ].each { path, texture -> standaloneItem(path, texture) } standaloneItem("golden_egg", "anotherworld:item/golden_egg") + standaloneItem("titanium_drill", "anotherworld:item/titanium_drill") // Lootbox-only titanium uniques: diamond-grade properties, 7.5 mining speed and unbreakable. def titaniumEquipment = [ @@ -431,6 +467,12 @@ tasks.register("generateAnotherWorldResources") { standaloneItem("${family}_${part}", "anotherworld:item/${family}_${part}") } } + shaped("safe_block", "building", ["GGG", "GCG", "GGG"], + [G: "minecraft:gold_block", C: "minecraft:chest"], "anotherworld:safe_block") + shaped("titanium_drill", "equipment", ["TTT", "RPR", "FDF"], [ + T: "anotherworld:titanium_ingot", R: "minecraft:redstone", P: "minecraft:piston", + F: "minecraft:iron_block", D: "minecraft:diamond_pickaxe" + ], "anotherworld:titanium_drill") // The five full mossy-wood blocks persist four visual growth stages. def mossModel = { String base, int level -> level == 0 ? base : "${base}_moss_${level}" } @@ -652,7 +694,7 @@ tasks.register("generateAnotherWorldResources") { tag("item", "minecraft", "stairs", coloredBrickStairs) tag("block", "minecraft", "slabs", coloredBrickSlabs) tag("item", "minecraft", "slabs", coloredBrickSlabs) - def pickaxe = ["anotherworld:mirror", "anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:raw_silver_block", "anotherworld:silver_block", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"] + def pickaxe = ["anotherworld:mirror", "anotherworld:safe_block", "anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:raw_silver_block", "anotherworld:silver_block", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"] pickaxe.addAll(coloredBricks) pickaxe.addAll(coloredBrickStairs) pickaxe.addAll(coloredBrickSlabs) @@ -660,7 +702,7 @@ tasks.register("generateAnotherWorldResources") { tag("block", "minecraft", "mineable/shovel", coloredClay + ["anotherworld:black_sand"]) tag("block", "minecraft", "mineable/hoe", coloredLeaves + flowerBlocks + ["anotherworld:turquoise_moss", "anotherworld:giant_flower_stem", "anotherworld:giant_flower_pistil"]) tag("block", "minecraft", "needs_iron_tool", ["anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:raw_silver_block", "anotherworld:silver_block"]) - tag("block", "minecraft", "needs_diamond_tool", ["anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"]) + tag("block", "minecraft", "needs_diamond_tool", ["anotherworld:safe_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"]) [ "gems/ruby": ["anotherworld:ruby"], "gems/sapphire": ["anotherworld:sapphire"], "raw_materials/silver": ["anotherworld:raw_silver"], "ingots/silver": ["anotherworld:silver_ingot"], "nuggets/silver": ["anotherworld:silver_nugget"], @@ -1452,13 +1494,13 @@ tasks.register("generateAnotherWorldResources") { def languageEntries = [ en_us: [ description: "Owns the material things that make up the world.", tab: "Another World", - mirror: "Mirror", black_sand: "Black Sand", ruby_ore: "Ruby Ore", deepslate_ruby_ore: "Deepslate Ruby Ore", ruby_block: "Block of Ruby", + mirror: "Mirror", safe_block: "Safe Block", black_sand: "Black Sand", ruby_ore: "Ruby Ore", deepslate_ruby_ore: "Deepslate Ruby Ore", ruby_block: "Block of Ruby", sapphire_ore: "Sapphire Ore", deepslate_sapphire_ore: "Deepslate Sapphire Ore", sapphire_block: "Block of Sapphire", deepslate_silver_ore: "Deepslate Silver Ore", raw_silver_block: "Block of Raw Silver", silver_block: "Block of Silver", deepslate_titanium_ore: "Deepslate Titanium Ore", raw_titanium_block: "Block of Raw Titanium", titanium_block: "Block of Titanium", ruby: "Ruby", sapphire: "Sapphire", raw_silver: "Raw Silver", silver_ingot: "Silver Ingot", silver_nugget: "Silver Nugget", silver_rod: "Silver Rod", silver_plate: "Silver Plate", silver_gear: "Silver Gear", aircraft_cockpit: "Aircraft Cockpit", - raw_titanium: "Raw Titanium", titanium_ingot: "Titanium Ingot", titanium_nugget: "Titanium Nugget", golden_egg: "Golden Egg", + raw_titanium: "Raw Titanium", titanium_ingot: "Titanium Ingot", titanium_nugget: "Titanium Nugget", titanium_drill: "Titanium Drill", golden_egg: "Golden Egg", titanium_sword: "Titanium Sword", titanium_shovel: "Titanium Shovel", titanium_pickaxe: "Titanium Pickaxe", titanium_axe: "Titanium Axe", titanium_hoe: "Titanium Hoe", titanium_spear: "Titanium Spear", titanium_shears: "Titanium Shears", titanium_helmet: "Titanium Helmet", titanium_chestplate: "Titanium Chestplate", titanium_leggings: "Titanium Leggings", titanium_boots: "Titanium Boots", @@ -1469,13 +1511,13 @@ tasks.register("generateAnotherWorldResources") { ], fr_fr: [ description: "Possède les choses matérielles qui composent le monde.", tab: "Another World", - mirror: "Miroir", black_sand: "Sable noir", ruby_ore: "Minerai de rubis", deepslate_ruby_ore: "Minerai de rubis des abîmes", ruby_block: "Bloc de rubis", + mirror: "Miroir", safe_block: "Bloc de coffre-fort", black_sand: "Sable noir", ruby_ore: "Minerai de rubis", deepslate_ruby_ore: "Minerai de rubis des abîmes", ruby_block: "Bloc de rubis", sapphire_ore: "Minerai de saphir", deepslate_sapphire_ore: "Minerai de saphir des abîmes", sapphire_block: "Bloc de saphir", deepslate_silver_ore: "Minerai d’argent des abîmes", raw_silver_block: "Bloc d’argent brut", silver_block: "Bloc d’argent", deepslate_titanium_ore: "Minerai de titane des abîmes", raw_titanium_block: "Bloc de titane brut", titanium_block: "Bloc de titane", ruby: "Rubis", sapphire: "Saphir", raw_silver: "Argent brut", silver_ingot: "Lingot d’argent", silver_nugget: "Pépite d’argent", silver_rod: "Tige d’argent", silver_plate: "Plaque d’argent", silver_gear: "Engrenage d’argent", aircraft_cockpit: "Cockpit d’avion", - raw_titanium: "Titane brut", titanium_ingot: "Lingot de titane", titanium_nugget: "Pépite de titane", golden_egg: "Œuf doré", + raw_titanium: "Titane brut", titanium_ingot: "Lingot de titane", titanium_nugget: "Pépite de titane", titanium_drill: "Foreuse en titane", golden_egg: "Œuf doré", titanium_sword: "Épée en titane", titanium_shovel: "Pelle en titane", titanium_pickaxe: "Pioche en titane", titanium_axe: "Hache en titane", titanium_hoe: "Houe en titane", titanium_spear: "Lance en titane", titanium_shears: "Cisailles en titane", titanium_helmet: "Casque en titane", titanium_chestplate: "Plastron en titane", titanium_leggings: "Jambières en titane", titanium_boots: "Bottes en titane", @@ -1486,13 +1528,13 @@ tasks.register("generateAnotherWorldResources") { ], ru_ru: [ description: "Содержит материальные элементы, из которых состоит мир.", tab: "Another World", - mirror: "Зеркало", black_sand: "Чёрный песок", ruby_ore: "Рубиновая руда", deepslate_ruby_ore: "Рубиноносный глубинный сланец", ruby_block: "Рубиновый блок", + mirror: "Зеркало", safe_block: "Блок сейфа", black_sand: "Чёрный песок", ruby_ore: "Рубиновая руда", deepslate_ruby_ore: "Рубиноносный глубинный сланец", ruby_block: "Рубиновый блок", sapphire_ore: "Сапфировая руда", deepslate_sapphire_ore: "Сапфироносный глубинный сланец", sapphire_block: "Сапфировый блок", deepslate_silver_ore: "Сереброносный глубинный сланец", raw_silver_block: "Блок необработанного серебра", silver_block: "Серебряный блок", deepslate_titanium_ore: "Титаноносный глубинный сланец", raw_titanium_block: "Блок необработанного титана", titanium_block: "Титановый блок", ruby: "Рубин", sapphire: "Сапфир", raw_silver: "Необработанное серебро", silver_ingot: "Серебряный слиток", silver_nugget: "Кусочек серебра", silver_rod: "Серебряный стержень", silver_plate: "Серебряная пластина", silver_gear: "Серебряная шестерня", aircraft_cockpit: "Кабина самолёта", - raw_titanium: "Необработанный титан", titanium_ingot: "Титановый слиток", titanium_nugget: "Кусочек титана", golden_egg: "Золотое яйцо", + raw_titanium: "Необработанный титан", titanium_ingot: "Титановый слиток", titanium_nugget: "Кусочек титана", titanium_drill: "Титановая дрель", golden_egg: "Золотое яйцо", titanium_sword: "Титановый меч", titanium_shovel: "Титановая лопата", titanium_pickaxe: "Титановая кирка", titanium_axe: "Титановый топор", titanium_hoe: "Титановая мотыга", titanium_spear: "Титановое копьё", titanium_shears: "Титановые ножницы", titanium_helmet: "Титановый шлем", titanium_chestplate: "Титановый нагрудник", titanium_leggings: "Титановые поножи", titanium_boots: "Титановые ботинки", @@ -1548,6 +1590,74 @@ tasks.register("generateAnotherWorldResources") { lang["options.miroir.reflection.tooltip"] = locale == "fr_fr" ? "Dose le mélange entre métal sombre et couleur réfléchie." : locale == "ru_ru" ? "Настраивает смешивание тёмного металла и отражённого цвета." : "Blends dark metal with the reflected color." + def vaultText = locale == "fr_fr" ? [ + "container.anotherworld.safe_vault": "Coffre-fort", + "tooltip.anotherworld.titanium_drill.use": "Maintenir sur un coffre-fort ennemi pour le forer.", + "tooltip.anotherworld.titanium_drill.consumed": "Consommée après un pillage réussi.", + "message.anotherworld.safe_vault.incomplete": "Le coffre-fort exige exactement 9 blocs appartenant au même joueur, posés en 3 × 3.", + "message.anotherworld.safe_vault.locked": "Ce coffre-fort appartient à un autre joueur.", + "message.anotherworld.safe_vault.formed": "Coffre-fort 3 × 3 lié à vous en %s, %s, %s.", + "message.anotherworld.safe_vault.already_owned": "Vous possédez déjà un coffre-fort actif.", + "message.anotherworld.safe_vault.break_not_owner": "Seul le propriétaire peut démonter ce bloc de coffre-fort.", + "message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.", + "message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.", + "message.anotherworld.safe_vault.inactive": "Ce coffre-fort n’est pas actif.", + "message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.", + "message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.", + "message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).", + "message.anotherworld.safe_vault.already_drilling": "Une autre foreuse attaque déjà ce coffre-fort.", + "message.anotherworld.safe_vault.alarm": "ALERTE : %s fore votre coffre-fort en %s, %s, %s !", + "message.anotherworld.safe_vault.drilling_started": "Forage lancé : gardez la foreuse dirigée vers la structure pendant 60 secondes.", + "message.anotherworld.safe_vault.drilling_progress": "Forage : %s %%", + "message.anotherworld.safe_vault.drilling_cancelled": "Forage interrompu.", + "message.anotherworld.safe_vault.raided_owner": "%s a pillé %s gemme(s) dans votre coffre-fort.", + "message.anotherworld.safe_vault.raided": "Pillage réussi : %s gemme(s) récupérée(s)." + ] : locale == "ru_ru" ? [ + "container.anotherworld.safe_vault": "Сейф", + "tooltip.anotherworld.titanium_drill.use": "Удерживайте на чужом сейфе, чтобы просверлить его.", + "tooltip.anotherworld.titanium_drill.consumed": "Расходуется после успешного ограбления.", + "message.anotherworld.safe_vault.incomplete": "Сейф требует ровно 9 блоков одного владельца, уложенных 3 × 3.", + "message.anotherworld.safe_vault.locked": "Этот сейф принадлежит другому игроку.", + "message.anotherworld.safe_vault.formed": "Сейф 3 × 3 привязан к вам в точке %s, %s, %s.", + "message.anotherworld.safe_vault.already_owned": "У вас уже есть активный сейф.", + "message.anotherworld.safe_vault.break_not_owner": "Разобрать этот блок может только владелец.", + "message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.", + "message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.", + "message.anotherworld.safe_vault.inactive": "Этот сейф не активен.", + "message.anotherworld.safe_vault.empty": "Этот сейф пуст.", + "message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.", + "message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).", + "message.anotherworld.safe_vault.already_drilling": "Этот сейф уже сверлит другой игрок.", + "message.anotherworld.safe_vault.alarm": "ТРЕВОГА: %s сверлит ваш сейф в точке %s, %s, %s!", + "message.anotherworld.safe_vault.drilling_started": "Сверление началось: держите дрель наведённой 60 секунд.", + "message.anotherworld.safe_vault.drilling_progress": "Сверление: %s %%", + "message.anotherworld.safe_vault.drilling_cancelled": "Сверление прервано.", + "message.anotherworld.safe_vault.raided_owner": "%s похитил(а) %s драгоценностей из вашего сейфа.", + "message.anotherworld.safe_vault.raided": "Ограбление удалось: получено %s драгоценностей." + ] : [ + "container.anotherworld.safe_vault": "Safe", + "tooltip.anotherworld.titanium_drill.use": "Hold on an enemy safe to drill it.", + "tooltip.anotherworld.titanium_drill.consumed": "Consumed after a successful raid.", + "message.anotherworld.safe_vault.incomplete": "A safe requires exactly 9 blocks owned by one player in a 3 × 3 square.", + "message.anotherworld.safe_vault.locked": "This safe belongs to another player.", + "message.anotherworld.safe_vault.formed": "3 × 3 safe linked to you at %s, %s, %s.", + "message.anotherworld.safe_vault.already_owned": "You already own an active safe.", + "message.anotherworld.safe_vault.break_not_owner": "Only the owner may dismantle this safe block.", + "message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.", + "message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.", + "message.anotherworld.safe_vault.inactive": "This safe is not active.", + "message.anotherworld.safe_vault.empty": "This safe is empty.", + "message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.", + "message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).", + "message.anotherworld.safe_vault.already_drilling": "Another drill is already attacking this safe.", + "message.anotherworld.safe_vault.alarm": "ALARM: %s is drilling your safe at %s, %s, %s!", + "message.anotherworld.safe_vault.drilling_started": "Drilling started: keep aiming at the structure for 60 seconds.", + "message.anotherworld.safe_vault.drilling_progress": "Drilling: %s %%", + "message.anotherworld.safe_vault.drilling_cancelled": "Drilling interrupted.", + "message.anotherworld.safe_vault.raided_owner": "%s stole %s gem item(s) from your safe.", + "message.anotherworld.safe_vault.raided": "Raid successful: recovered %s gem item(s)." + ] + lang.putAll(vaultText) def tagTranslations = locale == "fr_fr" ? [ "tag.item.anotherworld.lavender_logs": "Bûches de lavande", "tag.item.anotherworld.ebony_logs": "Bûches d’ébène", "tag.item.anotherworld.mossy_logs": "Bûches moussues", "tag.item.anotherworld.blueberry_logs": "Bûches de myrtille", "tag.item.anotherworld.colored_bricks": "Blocs de briques colorées", "tag.item.anotherworld.colored_brick_items": "Briques colorées", "tag.item.anotherworld.colored_clay": "Argiles colorées", "tag.item.anotherworld.colored_clay_balls": "Boules d’argile colorées", "tag.item.anotherworld.colored_flower_blocks": "Blocs de fleurs colorés", "tag.item.anotherworld.colored_leaves": "Feuilles colorées", @@ -1574,7 +1684,7 @@ tasks.register("generateAnotherWorldResources") { names.findAll { key, ignored -> !(key in ["description", "tab"]) }.each { path, name -> def registry = path in (["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget", "silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit", - "raw_titanium", "titanium_ingot", "titanium_nugget", "golden_egg"] + titaniumEquipment) ? "item" : "block" + "raw_titanium", "titanium_ingot", "titanium_nugget", "titanium_drill", "golden_egg"] + titaniumEquipment) ? "item" : "block" lang["${registry}.anotherworld.${path}"] = name } rainbowColors.each { color -> @@ -1656,7 +1766,7 @@ tasks.named("sourcesJar") { tasks.register("verifyAnotherWorldCatalog") { group = "verification" - description = "Checks the Another World alpha.49 catalog and climate-aware biome mixer." + description = "Checks the Another World alpha.50 catalog, player vault and climate-aware biome mixer." dependsOn tasks.named("generateAnotherWorldResources") inputs.file(file("src/main/resources/data/anotherworld/sanctuary_biome_catalog.json")) inputs.file(rootProject.file("sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary.json")) @@ -1794,7 +1904,7 @@ tasks.register("verifyAnotherWorldCatalog") { "src/main/resources/assets/redstoner/textures/item/weather_gauge.png", "src/main/resources/data/redstoner/recipe/weather_gauge.json" ] - if (project.version.toString() != "0.0.0-alpha.49" + if (project.version.toString() != "0.0.0-alpha.50" || sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44" || sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2 || !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")') @@ -2217,7 +2327,7 @@ tasks.register("verifyAnotherWorldCatalog") { def parts = ["planks", "log", "wood", "stripped_log", "stripped_wood", "leaves", "sapling", "stairs", "slab", "fence", "fence_gate", "door", "trapdoor", "pressure_plate", "button"] def signParts = ["sign", "wall_sign", "hanging_sign", "wall_hanging_sign"] def blocks = [ - "mirror", "black_sand", "ruby_ore", "deepslate_ruby_ore", "ruby_block", "sapphire_ore", "deepslate_sapphire_ore", "sapphire_block", + "mirror", "safe_block", "black_sand", "ruby_ore", "deepslate_ruby_ore", "ruby_block", "sapphire_ore", "deepslate_sapphire_ore", "sapphire_block", "deepslate_silver_ore", "raw_silver_block", "silver_block", "deepslate_titanium_ore", "raw_titanium_block", "titanium_block", "lavender", "pale_lavender", "pink_lavender", "blue_lavender", "dusk_lavender", "hibiscus", "narcissus", "green_bell_mushroom", "brown_shelf_mushroom", "rose", "blue_rose", "turquoise_moss", "giant_flower_stem", "giant_flower_pistil" @@ -2238,7 +2348,7 @@ tasks.register("verifyAnotherWorldCatalog") { "titanium_spear", "titanium_shears", "titanium_helmet", "titanium_chestplate", "titanium_leggings", "titanium_boots" ] - def standaloneItems = ["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget", "silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit", "raw_titanium", "titanium_ingot", "titanium_nugget", "golden_egg"] + titaniumEquipmentIds + def standaloneItems = ["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget", "silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit", "raw_titanium", "titanium_ingot", "titanium_nugget", "titanium_drill", "golden_egg"] + titaniumEquipmentIds standaloneItems.addAll(colors.collect { "${it}_brick" }) standaloneItems.addAll(colors.collect { "${it}_clay_ball" }) standaloneItems.addAll(woodFamilies.collectMany { family -> ["${family}_boat", "${family}_chest_boat"] }) @@ -3098,6 +3208,7 @@ tasks.register("verifyAnotherWorldCatalog") { tasks.named("check") { dependsOn tasks.named("verifyAnotherWorldCatalog") + dependsOn tasks.named("vaultRulesSmoke") } tasks.register("verifyClimateBiomeMixer") { @@ -3110,7 +3221,7 @@ tasks.register("verifyClimateBiomeMixer") { "pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json")) def mixer = file( "src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text - if (project.version.toString() != "0.0.0-alpha.49" + if (project.version.toString() != "0.0.0-alpha.50" || migration.source?.pack_version != "26.2.0-alpha.188" || migration.target?.pack_version != "26.2.0-alpha.189" || migration.target?.modules?.anotherworld != "0.0.0-alpha.48" diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java index 6288854..37022be 100644 --- a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java @@ -4,6 +4,7 @@ import fr.koka99cab.canon.CanonMod; import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem; import fr.koka99cab.sanctuary26.anotherworld.network.AnotherWorldNetworking; import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldRegistries; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService; import net.fabricmc.api.ModInitializer; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -12,7 +13,7 @@ import org.slf4j.LoggerFactory; public final class AnotherWorldMod implements ModInitializer { public static final String MOD_ID = "anotherworld"; public static final String DISPLAY_NAME = "Another World"; - public static final int CURRENT_DATA_VERSION = 1; + public static final int CURRENT_DATA_VERSION = 2; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public AnotherWorldMod() { @@ -25,6 +26,7 @@ public final class AnotherWorldMod implements ModInitializer { @Override public void onInitialize() { AnotherWorldRegistries.initialize(); + VaultService.initialize(); WeatherCompassItem.registerFrameInteraction(); CanonMod.initialize(); AnotherWorldNetworking.initialize(); diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/SafeVaultBlock.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/SafeVaultBlock.java new file mode 100644 index 0000000..d9c35bd --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/SafeVaultBlock.java @@ -0,0 +1,117 @@ +package fr.koka99cab.sanctuary26.anotherworld.block; + +import com.mojang.serialization.MapCodec; +import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity; +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultData; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultDrillingService; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultStructure; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.phys.BlockHitResult; + +public final class SafeVaultBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(SafeVaultBlock::new); + public static final EnumProperty FACING = BlockStateProperties.HORIZONTAL_FACING; + + public SafeVaultBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)); + } + + @Override protected MapCodec codec() { return CODEC; } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new SafeVaultBlockEntity(pos, state); } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override public BlockState getStateForPlacement(BlockPlaceContext context) { + return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite()); + } + @Override protected BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue(FACING, rotation.rotate(state.getValue(FACING))); + } + @Override protected BlockState mirror(BlockState state, Mirror mirror) { + return rotate(state, mirror.getRotation(state.getValue(FACING))); + } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING); + } + + @Override + public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) { + super.setPlacedBy(level, pos, state, placer, stack); + if (!(level instanceof ServerLevel serverLevel) || !(placer instanceof ServerPlayer player)) return; + if (level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity) { + blockEntity.setOwner(player.getUUID()); + VaultService.onSegmentPlaced(serverLevel, pos, player); + } + } + + @Override + protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, + Player player, InteractionHand hand, BlockHitResult hit) { + if (!stack.is(AnotherWorldItems.TITANIUM_DRILL)) return InteractionResult.TRY_WITH_EMPTY_HAND; + if (level.isClientSide()) { + player.startUsingItem(hand); + return InteractionResult.SUCCESS; + } + if (player instanceof ServerPlayer serverPlayer + && VaultDrillingService.begin(serverPlayer, (ServerLevel) level, pos, hand)) { + player.startUsingItem(hand); + return InteractionResult.SUCCESS_SERVER; + } + return InteractionResult.CONSUME; + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (level.isClientSide()) return InteractionResult.SUCCESS; + VaultStructure.Formed formed = VaultStructure.find(level, pos).orElse(null); + if (formed == null) { + player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.incomplete")); + return InteractionResult.CONSUME; + } + ServerLevel serverLevel = (ServerLevel) level; + VaultData data = VaultData.get(serverLevel.getServer()); + if (!data.isRegistered(serverLevel, formed.center(), formed.owner()) + && !VaultService.tryRegister(serverLevel, formed, player instanceof ServerPlayer sp ? sp : null)) { + return InteractionResult.CONSUME; + } + if (!formed.owner().equals(player.getUUID())) { + player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.locked")); + return InteractionResult.CONSUME; + } + if (level.getBlockEntity(formed.center()) instanceof SafeVaultBlockEntity controller) { + player.openMenu(controller); + return InteractionResult.SUCCESS_SERVER; + } + return InteractionResult.CONSUME; + } + + @Override + protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos, + boolean movedByPiston) { + if (!movedByPiston) VaultData.get(level.getServer()).unregisterNear(level, pos); + super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston); + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/entity/SafeVaultBlockEntity.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/entity/SafeVaultBlockEntity.java new file mode 100644 index 0000000..9b45ba2 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/block/entity/SafeVaultBlockEntity.java @@ -0,0 +1,60 @@ +package fr.koka99cab.sanctuary26.anotherworld.block.entity; + +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlockEntities; +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultData; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** One owned segment of a player vault. The shared inventory lives in versioned world data. */ +public final class SafeVaultBlockEntity extends BlockEntity implements MenuProvider { + private UUID owner; + + public SafeVaultBlockEntity(BlockPos pos, BlockState state) { + super(AnotherWorldBlockEntities.SAFE_VAULT, pos, state); + } + + public UUID owner() { + return owner; + } + + public void setOwner(UUID owner) { + this.owner = owner; + setChanged(); + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner); + } + + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + owner = input.read("Owner", UUIDUtil.CODEC).orElse(null); + } + + @Override + public Component getDisplayName() { + return Component.translatable("container.anotherworld.safe_vault"); + } + + @Override + public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) { + if (owner == null || !owner.equals(player.getUUID()) || !(level instanceof ServerLevel serverLevel)) return null; + return ChestMenu.threeRows(containerId, inventory, + VaultData.get(serverLevel.getServer()).container(owner, serverLevel, getBlockPos())); + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumDrillItem.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumDrillItem.java new file mode 100644 index 0000000..ea1bc88 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumDrillItem.java @@ -0,0 +1,41 @@ +package fr.koka99cab.sanctuary26.anotherworld.item; + +import fr.koka99cab.sanctuary26.anotherworld.vault.VaultDrillingService; +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.ItemUseAnimation; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; +import net.minecraft.world.level.Level; + +public final class TitaniumDrillItem extends Item { + public TitaniumDrillItem(Properties properties) { + super(properties); + } + + @Override + public void onUseTick(Level level, LivingEntity entity, ItemStack stack, int remainingUseTicks) { + if (entity instanceof ServerPlayer player) VaultDrillingService.tick(player, stack); + } + + @Override + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingUseTicks) { + if (entity instanceof ServerPlayer player) VaultDrillingService.cancel(player, true); + return false; + } + + @Override public int getUseDuration(ItemStack stack, LivingEntity entity) { return 72_000; } + @Override public ItemUseAnimation getUseAnimation(ItemStack stack) { return ItemUseAnimation.NONE; } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, + Consumer tooltip, TooltipFlag flag) { + tooltip.accept(Component.translatable("tooltip.anotherworld.titanium_drill.use").withStyle(ChatFormatting.GRAY)); + tooltip.accept(Component.translatable("tooltip.anotherworld.titanium_drill.consumed").withStyle(ChatFormatting.DARK_GRAY)); + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlockEntities.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlockEntities.java index d43e16a..bc98445 100644 --- a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlockEntities.java +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlockEntities.java @@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.anotherworld.registry; import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod; import fr.koka99cab.sanctuary26.anotherworld.block.entity.MirrorBlockEntity; +import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity; import java.util.ArrayList; import java.util.List; import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType; @@ -14,6 +15,11 @@ import net.minecraft.world.level.block.entity.BlockEntityTypes; public final class AnotherWorldBlockEntities { public static final BlockEntityType MIRROR = registerMirror(); + public static final BlockEntityType SAFE_VAULT = Registry.register( + BuiltInRegistries.BLOCK_ENTITY_TYPE, + AnotherWorldMod.id("safe_vault"), + FabricBlockEntityTypeBuilder.create(SafeVaultBlockEntity::new, AnotherWorldBlocks.SAFE_BLOCK).build() + ); private AnotherWorldBlockEntities() { } @@ -33,7 +39,7 @@ public final class AnotherWorldBlockEntities { hangingSignType.addValidBlock(family.hangingSign()); hangingSignType.addValidBlock(family.wallHangingSign()); }); - AnotherWorldMod.LOGGER.debug("[{}] Mirror block entity and custom sign compatibility registered.", AnotherWorldMod.DISPLAY_NAME); + AnotherWorldMod.LOGGER.debug("[{}] Mirror, safe-vault and custom sign block entities registered.", AnotherWorldMod.DISPLAY_NAME); } private static BlockEntityType registerMirror() { diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java index 9d9ef4f..6211c17 100644 --- a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java @@ -14,6 +14,7 @@ import fr.koka99cab.sanctuary26.anotherworld.block.PricklyRoseBlock; import fr.koka99cab.sanctuary26.anotherworld.block.ProgressiveMossBlock; import fr.koka99cab.sanctuary26.anotherworld.block.ProgressiveMossPillarBlock; import fr.koka99cab.sanctuary26.anotherworld.block.RainbowBlock; +import fr.koka99cab.sanctuary26.anotherworld.block.SafeVaultBlock; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -55,6 +56,7 @@ import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.block.state.properties.BlockSetType; import net.minecraft.world.level.block.state.properties.WoodType; import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.level.material.PushReaction; public final class AnotherWorldBlocks { public static final List DYE_COLORS = List.of( @@ -93,6 +95,16 @@ public final class AnotherWorldBlocks { .sound(SoundType.GLASS) .noOcclusion() ); + public static final SafeVaultBlock SAFE_BLOCK = register( + "safe_block", + SafeVaultBlock::new, + BlockBehaviour.Properties.ofFullCopy(Blocks.IRON_BLOCK) + .mapColor(MapColor.GOLD) + .requiresCorrectToolForDrops() + .strength(8.0F, 1_200.0F) + .sound(SoundType.METAL) + .pushReaction(PushReaction.BLOCK) + ); public static final Map RAINBOW_BLOCKS = registerRainbowBlocks(); public static final Map COLORED_BRICKS = registerColoredBlocks("bricks", Blocks.BRICKS); diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java index bdaf366..e3f00f2 100644 --- a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java @@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.anotherworld.registry; import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod; import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem; import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumEquipment; +import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumDrillItem; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -54,6 +55,11 @@ public final class AnotherWorldItems { public static final Item RAW_TITANIUM = register("raw_titanium"); public static final Item TITANIUM_INGOT = registerTrimMaterial("titanium_ingot", TITANIUM_TRIM_MATERIAL); public static final Item TITANIUM_NUGGET = register("titanium_nugget"); + public static final Item TITANIUM_DRILL = register( + "titanium_drill", + TitaniumDrillItem::new, + new Item.Properties().stacksTo(1) + ); public static final Item TITANIUM_SWORD = register( "titanium_sword", Item::new, diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultData.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultData.java new file mode 100644 index 0000000..9365489 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultData.java @@ -0,0 +1,274 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.core.NonNullList; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Container; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Global player-linked vault inventory, available to the Shop even when its structure is unloaded. */ +public final class VaultData extends SavedData { + public static final String SAVE_KEY = "safe_vaults"; + public static final int DATA_VERSION = 1; + private static final Identifier RUBY = Identifier.fromNamespaceAndPath("anotherworld", "ruby"); + private static final Identifier SAPPHIRE = Identifier.fromNamespaceAndPath("anotherworld", "sapphire"); + private static final Identifier EMERALD = Identifier.withDefaultNamespace("emerald"); + + private static final Codec SLOT_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("slot").forGetter(StoredSlot::slot), + ItemStack.CODEC.fieldOf("item").forGetter(StoredSlot::item) + ).apply(instance, StoredSlot::new)); + private static final Codec ACCOUNT_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("owner").forGetter(StoredAccount::owner), + SLOT_CODEC.listOf().optionalFieldOf("items", List.of()).forGetter(StoredAccount::items), + Codec.LONG.optionalFieldOf("protected_until", 0L).forGetter(StoredAccount::protectedUntil) + ).apply(instance, StoredAccount::new)); + private static final Codec ADDRESS_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("dimension").forGetter(VaultAddress::dimension), + Codec.LONG.fieldOf("pos").forGetter(VaultAddress::pos), + Codec.STRING.fieldOf("owner").forGetter(VaultAddress::owner) + ).apply(instance, VaultAddress::new)); + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion), + ACCOUNT_CODEC.listOf().optionalFieldOf("accounts", List.of()).forGetter(VaultData::storedAccounts), + ADDRESS_CODEC.listOf().optionalFieldOf("vaults", List.of()).forGetter(data -> data.vaults) + ).apply(instance, VaultData::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + AnotherWorldMod.id(SAVE_KEY), VaultData::new, CODEC, null); + + private int dataVersion; + private final Map accounts = new LinkedHashMap<>(); + private final List vaults = new ArrayList<>(); + + public VaultData() { + this(DATA_VERSION, List.of(), List.of()); + } + + private VaultData(int dataVersion, List accounts, List vaults) { + this.dataVersion = dataVersion; + for (StoredAccount stored : accounts == null ? List.of() : accounts) { + try { + UUID owner = UUID.fromString(stored.owner()); + NonNullList items = NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY); + for (StoredSlot slot : stored.items()) { + if (slot.slot() >= 0 && slot.slot() < items.size() && isCurrency(slot.item())) { + items.set(slot.slot(), slot.item().copy()); + } + } + this.accounts.put(owner, new Account(items, Math.max(0L, stored.protectedUntil()))); + } catch (IllegalArgumentException ignored) { + AnotherWorldMod.LOGGER.warn("[Vault] Ignored account with invalid owner {}.", stored.owner()); + } + } + if (vaults != null) this.vaults.addAll(vaults); + } + + public static VaultData get(MinecraftServer server) { + ServerLevel overworld = server.getLevel(Level.OVERWORLD); + if (overworld == null) throw new IllegalStateException("Overworld unavailable for vault data"); + VaultData data = overworld.getDataStorage().computeIfAbsent(TYPE); + if (data.dataVersion < DATA_VERSION) { + data.dataVersion = DATA_VERSION; + data.setDirty(); + } + return data; + } + + public boolean register(ServerLevel level, BlockPos center, UUID owner) { + String dimension = dimension(level); + String ownerId = owner.toString(); + for (VaultAddress address : vaults) { + if (address.owner().equals(ownerId)) { + return address.dimension().equals(dimension) && address.pos() == center.asLong(); + } + } + vaults.add(new VaultAddress(dimension, center.asLong(), ownerId)); + accounts.computeIfAbsent(owner, ignored -> new Account()); + setDirty(); + return true; + } + + public boolean isRegistered(ServerLevel level, BlockPos center, UUID owner) { + String dimension = dimension(level), ownerId = owner.toString(); + return vaults.stream().anyMatch(address -> address.dimension().equals(dimension) + && address.pos() == center.asLong() && address.owner().equals(ownerId)); + } + + public void unregisterNear(ServerLevel level, BlockPos member) { + String dimension = dimension(level); + boolean changed = vaults.removeIf(address -> { + if (!address.dimension().equals(dimension)) return false; + BlockPos center = BlockPos.of(address.pos()); + return center.getY() == member.getY() && Math.abs(center.getX() - member.getX()) <= 1 + && Math.abs(center.getZ() - member.getZ()) <= 1; + }); + if (changed) setDirty(); + } + + public Container container(UUID owner, ServerLevel level, BlockPos center) { + accounts.computeIfAbsent(owner, ignored -> new Account()); + return new VaultContainer(this, owner, level, center.immutable()); + } + + public int count(UUID owner, Item item) { + if (!isCurrency(item)) return 0; + Account account = accounts.get(owner); + if (account == null) return 0; + long count = 0L; + for (ItemStack stack : account.items) if (stack.is(item)) count += stack.getCount(); + return (int) Math.min(Integer.MAX_VALUE, count); + } + + public int remove(UUID owner, Item item, int amount) { + if (!isCurrency(item) || amount <= 0) return 0; + Account account = accounts.get(owner); + if (account == null) return 0; + int remaining = amount; + for (ItemStack stack : account.items) { + if (remaining <= 0) break; + if (!stack.is(item)) continue; + int removed = Math.min(remaining, stack.getCount()); + stack.shrink(removed); + remaining -= removed; + } + if (remaining != amount) setDirty(); + return amount - remaining; + } + + public boolean hasContents(UUID owner) { + Account account = accounts.get(owner); + return account != null && account.items.stream().anyMatch(stack -> !stack.isEmpty()); + } + + public List raidLoot(UUID owner) { + List loot = new ArrayList<>(); + for (Identifier id : List.of(RUBY, SAPPHIRE, EMERALD)) { + Item item = BuiltInRegistries.ITEM.getOptional(id).orElse(null); + if (item == null) continue; + int amount = VaultRules.raidLoot(count(owner, item)); + int removed = remove(owner, item, amount); + while (removed > 0) { + int stackSize = Math.min(removed, item.getDefaultMaxStackSize()); + loot.add(new ItemStack(item, stackSize)); + removed -= stackSize; + } + } + return List.copyOf(loot); + } + + public long protectionRemaining(UUID owner, long gameTime) { + Account account = accounts.get(owner); + return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime); + } + + public void protectAfterRaid(UUID owner, long gameTime) { + Account account = accounts.computeIfAbsent(owner, ignored -> new Account()); + account.protectedUntil = gameTime + VaultRules.RAID_PROTECTION_TICKS; + setDirty(); + } + + public static boolean isCurrency(ItemStack stack) { + return stack != null && !stack.isEmpty() && isCurrency(stack.getItem()); + } + + public static boolean isCurrency(Item item) { + if (item == null) return false; + Identifier id = BuiltInRegistries.ITEM.getKey(item); + return RUBY.equals(id) || SAPPHIRE.equals(id) || EMERALD.equals(id); + } + + private NonNullList items(UUID owner) { + return accounts.computeIfAbsent(owner, ignored -> new Account()).items; + } + + private List storedAccounts() { + List stored = new ArrayList<>(); + accounts.forEach((owner, account) -> { + List slots = new ArrayList<>(); + for (int index = 0; index < account.items.size(); index++) { + ItemStack stack = account.items.get(index); + if (!stack.isEmpty()) slots.add(new StoredSlot(index, stack)); + } + stored.add(new StoredAccount(owner.toString(), List.copyOf(slots), account.protectedUntil)); + }); + return List.copyOf(stored); + } + + private static String dimension(ServerLevel level) { + return level.dimension().identifier().toString(); + } + + private static final class Account { + private final NonNullList items; + private long protectedUntil; + private Account() { this(NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY), 0L); } + private Account(NonNullList items, long protectedUntil) { + this.items = items; + this.protectedUntil = protectedUntil; + } + } + + private static final class VaultContainer implements Container { + private final VaultData data; + private final UUID owner; + private final ServerLevel level; + private final BlockPos center; + + private VaultContainer(VaultData data, UUID owner, ServerLevel level, BlockPos center) { + this.data = data; + this.owner = owner; + this.level = level; + this.center = center; + } + + @Override public int getContainerSize() { return VaultRules.STORAGE_SLOTS; } + @Override public boolean isEmpty() { return data.items(owner).stream().allMatch(ItemStack::isEmpty); } + @Override public ItemStack getItem(int slot) { return data.items(owner).get(slot); } + @Override public ItemStack removeItem(int slot, int amount) { + ItemStack removed = ContainerHelper.removeItem(data.items(owner), slot, amount); + if (!removed.isEmpty()) setChanged(); + return removed; + } + @Override public ItemStack removeItemNoUpdate(int slot) { + ItemStack removed = ContainerHelper.takeItem(data.items(owner), slot); + if (!removed.isEmpty()) setChanged(); + return removed; + } + @Override public void setItem(int slot, ItemStack stack) { + if (stack != null && !stack.isEmpty() && !isCurrency(stack)) return; + data.items(owner).set(slot, stack == null ? ItemStack.EMPTY : stack); + setChanged(); + } + @Override public void setChanged() { data.setDirty(); } + @Override public boolean stillValid(Player player) { + if (player == null || !player.getUUID().equals(owner) + || player.distanceToSqr(center.getX() + 0.5D, center.getY() + 0.5D, center.getZ() + 0.5D) > 64.0D) + return false; + return data.isRegistered(level, center, owner) + && VaultStructure.find(level, center).filter(formed -> formed.center().equals(center)).isPresent(); + } + @Override public void clearContent() { data.items(owner).clear(); setChanged(); } + @Override public boolean canPlaceItem(int slot, ItemStack stack) { return isCurrency(stack); } + } + + private record StoredSlot(int slot, ItemStack item) {} + private record StoredAccount(String owner, List items, long protectedUntil) {} + private record VaultAddress(String dimension, long pos, String owner) {} +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultDrillingService.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultDrillingService.java new file mode 100644 index 0000000..ca89d9d --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultDrillingService.java @@ -0,0 +1,176 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod; +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; + +/** Server-authoritative continuous titanium-drill raid session. */ +public final class VaultDrillingService { + private static final Map SESSIONS = new HashMap<>(); + private static final Map ACTIVE_TARGETS = new HashMap<>(); + private static boolean initialized; + + private VaultDrillingService() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> cancel(handler.player, false)); + } + + public static boolean begin(ServerPlayer attacker, ServerLevel level, BlockPos clicked, InteractionHand hand) { + VaultStructure.Formed formed = VaultStructure.find(level, clicked).orElse(null); + if (formed == null) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.incomplete")); + return false; + } + if (formed.owner().equals(attacker.getUUID())) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.drill_own")); + return false; + } + VaultData data = VaultData.get(level.getServer()); + if (!data.isRegistered(level, formed.center(), formed.owner())) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive")); + return false; + } + if (!data.hasContents(formed.owner())) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty")); + return false; + } + ServerPlayer owner = level.getServer().getPlayerList().getPlayer(formed.owner()); + if (owner == null) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.owner_offline")); + return false; + } + long remaining = data.protectionRemaining(formed.owner(), level.getServer().overworld().getGameTime()); + if (remaining > 0L) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.protected", + Math.max(1L, (remaining + 1199L) / 1200L))); + return false; + } + Target target = Target.of(level, formed.center()); + UUID active = ACTIVE_TARGETS.get(target); + if (active != null && !active.equals(attacker.getUUID())) { + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.already_drilling")); + return false; + } + + cancel(attacker, false); + Session session = new Session(target, formed.owner(), hand, 0); + SESSIONS.put(attacker.getUUID(), session); + ACTIVE_TARGETS.put(target, attacker.getUUID()); + owner.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.alarm", + attacker.getDisplayName(), formed.center().getX(), formed.center().getY(), formed.center().getZ())); + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.drilling_started")); + level.playSound(null, clicked, SoundEvents.PISTON_EXTEND, SoundSource.BLOCKS, 1.0F, 0.65F); + AnotherWorldMod.LOGGER.info("[Vault] {} started drilling {}'s vault at {}.", + attacker.getGameProfile().name(), owner.getGameProfile().name(), formed.center().toShortString()); + return true; + } + + public static void tick(ServerPlayer attacker, ItemStack drill) { + Session session = SESSIONS.get(attacker.getUUID()); + if (session == null) return; + if (!drill.is(AnotherWorldItems.TITANIUM_DRILL) + || attacker.getUsedItemHand() != session.hand() + || !(attacker.level() instanceof ServerLevel level) + || !session.target().dimension().equals(dimension(level))) { + cancel(attacker, true); + return; + } + + HitResult hit = attacker.pick(attacker.blockInteractionRange() + 1.0D, 1.0F, false); + VaultStructure.Formed formed = hit instanceof BlockHitResult blockHit + ? VaultStructure.find(level, blockHit.getBlockPos()).orElse(null) : null; + if (formed == null || !formed.center().equals(session.target().center()) + || !formed.owner().equals(session.owner()) + || level.getServer().getPlayerList().getPlayer(session.owner()) == null) { + cancel(attacker, true); + attacker.stopUsingItem(); + return; + } + + int elapsed = session.elapsedTicks() + 1; + Session progressed = session.withElapsed(elapsed); + SESSIONS.put(attacker.getUUID(), progressed); + if (elapsed % 10 == 0) { + BlockPos center = formed.center(); + level.playSound(null, center, SoundEvents.ANVIL_LAND, SoundSource.BLOCKS, 0.35F, 1.65F); + level.sendParticles(ParticleTypes.ELECTRIC_SPARK, center.getX() + 0.5D, center.getY() + 1.0D, + center.getZ() + 0.5D, 5, 0.7D, 0.25D, 0.7D, 0.04D); + } + if (elapsed % 20 == 0) { + attacker.sendOverlayMessage(Component.translatable("message.anotherworld.safe_vault.drilling_progress", + VaultRules.progressPercent(elapsed))); + } + if (elapsed >= VaultRules.RAID_DURATION_TICKS) complete(attacker, level, progressed, formed); + } + + private static void complete(ServerPlayer attacker, ServerLevel level, Session session, + VaultStructure.Formed formed) { + VaultData data = VaultData.get(level.getServer()); + List loot = data.raidLoot(session.owner()); + if (loot.isEmpty()) { + cancel(attacker, true); + attacker.stopUsingItem(); + return; + } + int total = loot.stream().mapToInt(ItemStack::getCount).sum(); + loot.forEach(stack -> attacker.getInventory().placeItemBackInInventory(stack)); + data.protectAfterRaid(session.owner(), level.getServer().overworld().getGameTime()); + ItemStack drill = attacker.getItemInHand(session.hand()); + if (!attacker.hasInfiniteMaterials()) drill.shrink(1); + ServerPlayer owner = level.getServer().getPlayerList().getPlayer(session.owner()); + if (owner != null) owner.sendSystemMessage(Component.translatable( + "message.anotherworld.safe_vault.raided_owner", attacker.getDisplayName(), total)); + attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raided", total)); + level.playSound(null, formed.center(), SoundEvents.VAULT_OPEN_SHUTTER, SoundSource.BLOCKS, 1.0F, 0.8F); + level.sendParticles(ParticleTypes.CRIT, formed.center().getX() + 0.5D, formed.center().getY() + 1.0D, + formed.center().getZ() + 0.5D, 24, 0.8D, 0.4D, 0.8D, 0.12D); + AnotherWorldMod.LOGGER.info("[Vault] {} stole {} currency items from {} at {}.", + attacker.getGameProfile().name(), total, session.owner(), formed.center().toShortString()); + cancel(attacker, false); + attacker.stopUsingItem(); + } + + public static void cancel(ServerPlayer player, boolean notify) { + Session removed = SESSIONS.remove(player.getUUID()); + if (removed == null) return; + ACTIVE_TARGETS.remove(removed.target(), player.getUUID()); + if (notify) player.sendOverlayMessage(Component.translatable("message.anotherworld.safe_vault.drilling_cancelled")); + } + + public static boolean isActive(ServerLevel level, BlockPos center) { + return ACTIVE_TARGETS.containsKey(Target.of(level, center)); + } + + private static String dimension(ServerLevel level) { + return level.dimension().identifier().toString(); + } + + private record Target(String dimension, BlockPos center) { + private static Target of(ServerLevel level, BlockPos center) { + return new Target(VaultDrillingService.dimension(level), center.immutable()); + } + } + + private record Session(Target target, UUID owner, InteractionHand hand, int elapsedTicks) { + private Session withElapsed(int elapsed) { return new Session(target, owner, hand, elapsed); } + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRules.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRules.java new file mode 100644 index 0000000..9bc9335 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRules.java @@ -0,0 +1,25 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +/** Pure balancing rules shared by runtime code and smoke tests. */ +public final class VaultRules { + public static final int STRUCTURE_WIDTH = 3; + public static final int STRUCTURE_RADIUS = 1; + public static final int STRUCTURE_BLOCKS = STRUCTURE_WIDTH * STRUCTURE_WIDTH; + public static final int STORAGE_SLOTS = 27; + public static final int RAID_DURATION_TICKS = 60 * 20; + public static final int RAID_LOOT_PERCENT = 20; + public static final long RAID_PROTECTION_TICKS = 24L * 60L * 60L * 20L; + + private VaultRules() { + } + + public static int raidLoot(int stored) { + if (stored <= 0) return 0; + long rounded = ((long) stored * RAID_LOOT_PERCENT + 99L) / 100L; + return (int) Math.min(stored, Math.max(1L, rounded)); + } + + public static int progressPercent(int elapsedTicks) { + return Math.clamp((int) ((long) Math.max(0, elapsedTicks) * 100L / RAID_DURATION_TICKS), 0, 100); + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultService.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultService.java new file mode 100644 index 0000000..12f5a12 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultService.java @@ -0,0 +1,78 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod; +import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity; +import fr.koka99cab.sanctuary26.anotherworld.block.SafeVaultBlock; +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks; +import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryCurrencyStorage; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.Permissions; +import net.minecraft.world.entity.player.Player; + +/** Owns vault lifecycle, break protection and Sanctuary currency-storage integration. */ +public final class VaultService { + private static boolean initialized; + + private VaultService() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + SanctuaryCurrencyStorage.register(AnotherWorldMod.id("safe_vault"), new SanctuaryCurrencyStorage.Provider() { + @Override public int count(ServerPlayer player, net.minecraft.world.item.Item item) { + return VaultData.get(player.level().getServer()).count(player.getUUID(), item); + } + @Override public int remove(ServerPlayer player, net.minecraft.world.item.Item item, int amount) { + return VaultData.get(player.level().getServer()).remove(player.getUUID(), item, amount); + } + }); + PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> { + if (!state.is(AnotherWorldBlocks.SAFE_BLOCK) || !(level instanceof ServerLevel serverLevel) + || !(blockEntity instanceof SafeVaultBlockEntity segment)) return true; + if (isOperator(player)) return true; + if (segment.owner() == null || !segment.owner().equals(player.getUUID())) { + player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.break_not_owner")); + return false; + } + VaultStructure.Formed formed = VaultStructure.find(serverLevel, pos).orElse(null); + if (formed != null && (VaultData.get(serverLevel.getServer()).hasContents(formed.owner()) + || VaultDrillingService.isActive(serverLevel, formed.center()))) { + player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty_before_break")); + return false; + } + return true; + }); + VaultDrillingService.initialize(); + } + + public static void onSegmentPlaced(ServerLevel level, net.minecraft.core.BlockPos pos, ServerPlayer player) { + VaultStructure.find(level, pos).ifPresent(formed -> { + var facing = level.getBlockState(pos).getValue(SafeVaultBlock.FACING); + formed.members().forEach(member -> level.setBlock(member, + level.getBlockState(member).setValue(SafeVaultBlock.FACING, facing), + net.minecraft.world.level.block.Block.UPDATE_CLIENTS)); + tryRegister(level, formed, player); + }); + } + + public static boolean tryRegister(ServerLevel level, VaultStructure.Formed formed, ServerPlayer player) { + VaultData data = VaultData.get(level.getServer()); + if (data.register(level, formed.center(), formed.owner())) { + if (player != null) player.sendSystemMessage(Component.translatable( + "message.anotherworld.safe_vault.formed", + formed.center().getX(), formed.center().getY(), formed.center().getZ())); + return true; + } + if (player != null) player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.already_owned")); + return false; + } + + private static boolean isOperator(Player player) { + return player instanceof ServerPlayer serverPlayer && serverPlayer.createCommandSourceStack().permissions() + .hasPermission(Permissions.COMMANDS_GAMEMASTER); + } +} diff --git a/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultStructure.java b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultStructure.java new file mode 100644 index 0000000..f0369e5 --- /dev/null +++ b/anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultStructure.java @@ -0,0 +1,61 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity; +import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; + +/** Detects one isolated horizontal 3x3 platform owned by a single player. */ +public final class VaultStructure { + private VaultStructure() { + } + + public static Optional find(Level level, BlockPos member) { + if (level == null || member == null) return Optional.empty(); + for (int offsetX = -VaultRules.STRUCTURE_RADIUS; offsetX <= VaultRules.STRUCTURE_RADIUS; offsetX++) { + for (int offsetZ = -VaultRules.STRUCTURE_RADIUS; offsetZ <= VaultRules.STRUCTURE_RADIUS; offsetZ++) { + BlockPos center = member.offset(-offsetX, 0, -offsetZ); + Formed formed = validate(level, center); + if (formed != null && formed.contains(member)) return Optional.of(formed); + } + } + return Optional.empty(); + } + + private static Formed validate(Level level, BlockPos center) { + UUID owner = null; + List members = new ArrayList<>(VaultRules.STRUCTURE_BLOCKS); + for (int x = -VaultRules.STRUCTURE_RADIUS; x <= VaultRules.STRUCTURE_RADIUS; x++) { + for (int z = -VaultRules.STRUCTURE_RADIUS; z <= VaultRules.STRUCTURE_RADIUS; z++) { + BlockPos pos = center.offset(x, 0, z); + if (!level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK) + || !(level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity) + || blockEntity.owner() == null) return null; + if (owner == null) owner = blockEntity.owner(); + else if (!owner.equals(blockEntity.owner())) return null; + members.add(pos.immutable()); + } + } + + // Reject ambiguous 3x4/4x3 carpets: a vault is one discrete 3x3 structure. + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + if (Math.abs(x) <= 1 && Math.abs(z) <= 1) continue; + if (level.getBlockState(center.offset(x, 0, z)).is(AnotherWorldBlocks.SAFE_BLOCK)) return null; + } + } + return new Formed(center.immutable(), owner, List.copyOf(members)); + } + + public record Formed(BlockPos center, UUID owner, List members) { + public boolean contains(BlockPos pos) { + return pos != null && pos.getY() == center.getY() + && Math.abs(pos.getX() - center.getX()) <= VaultRules.STRUCTURE_RADIUS + && Math.abs(pos.getZ() - center.getZ()) <= VaultRules.STRUCTURE_RADIUS; + } + } +} diff --git a/anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/bottom.png b/anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..5f42807d5a568b6cfbd0775d0531a06e4ac64eac GIT binary patch literal 4819 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 FV1hYgs=ojL literal 0 HcmV?d00001 diff --git a/anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/side.png b/anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault/side.png new file mode 100644 index 0000000000000000000000000000000000000000..be2e040d51c3cb04442723114843ecfa520666e0 GIT binary patch literal 5048 zcmV;p6G!ZcP)Px|a!Eu%RA_6eEJi5mqcaB8i+C+X0X5%y`Z@-F>?IciH!< zT0GRR`-~GQ@PGvSrGLNPwRf$x>RaFSU8{c5eD(bb0w@4(?Lig4bReK1z&4`ZBh~^! zEIlUAX$PT{9?5c0Nn;ui0hSIWy_rowHNBr0ki!BD>~IU zK+Q|YH~tZ*qGb&aegvL;0c-(JZfMt!u~#D)!7mJDwL+}J)Qh{9YO_IgLM1~0agw4B zG|d(6$^$UXpv&xh?bVjU`i1dq53Oj_}F`xt#ZkK)Bf< z{XpAqP(Py4pu>d8K!y`=-dB+e%)c4$fO(OdV&M@jy(Wi3$z&_42Ua{ zzC%Mp%u2{ChL-K)HHeb^rDXW<<$sP_=D7JWh_ZeAoUB|zgkolhV}vrt6qfPfhOTLt zJ$pj4yP+_1rmy}2+3J|$#h<|d-G6(9C^>fNkgWlY#4IAa9=n^ERs&dv%{G-Sz0>f$l`f-3sv#{@|BrHfxqY{8{`T{cGZr7E>BDFF`?@Yg}zn6cLz! z+*mXQj2L@M1rP?b*`heYB!I(AJz-Q(W%2RPaP@^(aJ#_n=7wx>i0(YH9T~HcYPmqd z1R0p45;u1^tJvD1hIBsz-c>L=JV5$^uxSX3g5vOy;kAE>d;i}6jRbKZ5qggX7&a3c zJ?aDE6Y+?#m^h{k7kwZ$4U#!D%Q41c3r`pgs3Cv(@6dkf7nwbON;tNdF2nRA;wQRx zgsDKwjA=a*atD>epSMig2~~)_$FE14-45AWx`$huzNOvm7%p~5?jS1t@BAW2@M`2&H!Mh36mLu6o{EXJe%`1{di6B^55q2^Z%Iq2UchZ z*sh{_Fk^SUX8rI6GK-Fp@uDHl4AZu!{eyFE9CaJ?*ANtDM}` zRIVb1mg%iE+71k~%s+a-qhI?Pk_pZEHONHFdr(2FP|UNG=tMAL%pFTm6ls~-k!G_d z+j*8B|1?)$c!l|UA26Qxgqr|$hTo0Mjt;0#mS{5~8Zd_iQf8Pw;)_6OETyqbxu<^S z06~cN9Mz(NeuQblKD)#fOJtryh!kdqIN|iC-^bhk`CDXdq}#6XO^(Kt&6}M^C!Zh` z`T>C8GW>c+mMeX~0}QiI{(Xk8evW!R$Cie$o02Hob@*nYziv=f;&qSa4u9D({f`^2 z|M&*8*=Nv&P*ye5TU*BOZOJr86?RuU+NP!b-Zg$V;KztYB{UQD{Zq^^@XSxVm&@0# zK`gUnIyx6$sE%*P|ar8a-ZN_GNgU&isH1wgb_t?&n<8#HPhoA z8Y9E`4(&ZsIc#NVhLLs{F+J=+waKVf6Vyi zO`6;8lc5W&o;e`2ia1L>&k&a*Hp6svo(Ao6kI4cWGv+_^X}T}}3wAd*TwGYZw2a@| zf*5=*Oq+r6jcdAXPumVyV+hTJ-}dAHsuYWY7$Phk)>^X6GWiK-4O|Z_k1Vlt=;Mj) z`G)%B9@E>~BrC>%hRE5EzQFYtFB#Szae;P#i=C%iPYFUr>{Eod=jb%iH8)UHDM`#7 zI(in(1*#B+fVUA_W{lsuqTLN-6UfCxXa^vsJQATgt5KlsCRAfmx>1oKFzyDXVZu}n zhKX(%m>VqI?8ZjlC-fI21$v`uPoL4 z2$^=t;XX1yUg4vm{o{w|fzZEk#c;D>3O#a>7{S*AH!o9q^;a6qc&4&38WGu_^!*#fXEUjA8bUVVkWThm@& zW5y8#>{!4U(Fn1dsOuatP%aZK=JR|X6{R0M-FZ*H8IaNAnuz8G?KMTd56O7I7fG~+ zF3m$!e4e3&OBsArx+fRp?>pt{)rSZcF$Qs2stWJC>$=<0fNHh^psW^Xgu1S=XXR~) zgrX==pXG=uUFTEOB|*Ka5=@FkhDHc;gMZl5Z^jh)ZA^w%P|aN0KLTCTqRSkyApMja zGRdB&MVt~8SbGpKa**(4o(gNChuM6EU1T)tH5^vh+OfUqQsSy$a(nnpNt?)R2Vy@- zeO4eM41=dS&B=eDq%O_(jTw4qn+drb@dXqI3*u(Pb|Y=uP`HxC=@FDUaR_%$8uPT? zFt_k*cUN~6h!k1caAW$eZ6Lh0p=bhq*HIkIp^NA`&@~fPX=s}X4Uw*yU@i~?W|)XU ziA_LnCfYVtUG9E{J94a62>??qsDcbWReYJDIyyjeM|1_>m#A*9eQU*Voi?%Cc)I55 zE;=zPUALjEXUH&t2!3v;W)(7cWQee^l&b>GOk$xfP%N^x-j*>Y zdjzU7ZSwLkRVe+Cx*y!uaH`M`$ZgaS{B8IX!qVlJmU?lVZuP0inH{XqVqdz|8D*8C z(X}02?-BDfFjFm5mBsjonIbGL)iOhKb2p~0?Dt1;?@)bKAVA-B%;q(A2+64H(8P0{G2{%`@7zVRN}*2p&PO-mnHWMOyW0z?KvQ}xkK)J{g zwcdF8!Bf>0tctYvZaUg#yI020cg<4=*mo@i3~}EViXIAwf8*a#eB^IoESTKf)yb=u z&QjF69$RI}_uHtR6^Km?(>@xRzCF-(0oN$5QQ9^cORvZ_;AcX83pcNDlruY9B<@Es zHt}`6SRt9vHEYaU4M(4N1|=|QL?@4WkCE)QD>p~R3HF}0&4xa-paJ{*5n(%G_JJRn z2;InJ1y(lsI7L*Ut}VV!ZN>Z~C%czZEDOYg1Yngho9BqJbfYp#%J{Z&U<{^#a6RB= zImF2P;Na<&(SV3W#2}eNT!zuGr_Mg9QuPT6YK6kguw{-mo^akV_@271;L0;vC|4EQ zr|xs#dHOM;KzUG5&$8s0fb}tD=RP1t(9EEPrLOao(W4NCh#5S?)FLFP+0qavOMRAB zpZNW~AdMc=*nNUfpIY3RFnr~6EMNXK?jUD(b%TGnCVS5z#VjqeoMaRt=%y#n9IPzm zGEX(CwR_C_Tfl+{!)O>rflWx`g5qjNtPE;}Od??D{4paPVLP$C+F%be-u|`Mn7{Na zVwI)|tTobYCp1i$`65wc+ikH$PP@B=Dit|#J7VjM&bNptw#vv>Gcq!Ydvn^sv%PL{ zi;C>5rhcYK8*65goDk@TKtFiq8kt9>8zWiaaI=g!bBJ@~Hf8zAO&sVIZWm~VhS`H> z@J$ME-)~Wk%sk9QC`-X))l(XEYfrO%Lb-Ytagp%)uTXsK?=oAgn7{i0(@jtL?j=Nz zJ*crqGY;N$NWI9>K?zqK&gOUn&O7A5(GLkS^}L|^$RYLfGqTDim3vSi){={1l93`9 z>^e}r{2W7OSRAf!VdVVt-{8TgKE(3u01XCKmf0*rvJ8zLr#>YLKNrekiOT~1CJ+@Y z5N%HT=40|f8Jq(TVhrI)OXw!@XAbaPpg1WA-AE>u-F3_Az4yqM&|hy+pnq+H0IsqW z@15g^NRG0-X(@t1tPtmh>Fu7w=MGV?G@DI=VaOS`p5nd`yL4J(4-6Ut#^z<>@8vyg zp0RuUh<0~{7-jtLzfAFwzlkD@h2W;NoHhosc}Dqx`^e0ZSw(zc9D0i5isHDSzu97@ zKs~Qf0ry;veZD~MS;n`v_^zex2M#~*fZ3lrq<-!IjLw|r_5G0@RDdtqAREl zjwxJ4c+?QONaz*08tB^wYlO|CD|AsI#*(Ah%rZN<&-t%@jR!yZ9@_SX`QZVmL34}A zlwz@;(>km(uyP4P@zMT#;9<{t{ymDrGnzM3UEocNjydbiHRWl^^&36rg*lI3eIrrt z2hLbLzhd*vE4+cxx5Rctdry9^CW?hVVl=S6?(vr$>wBKr{UfekzhQow^YEMJxJNB; zN%hiwt{x3k_Yc`z4TKP>4wbkbX`ggt$AYzyHUw7F3@bgZnC(ws&oWlCW5ha&vpLMt z5vYb8aW^2w@$84r7~i_azkEXd>?x1_?Q2|r<1worxks6upk;v>Q?FHveaCNKTgbCD zU1O>C@0G0J?f>{calnI5eTeqa1>mVpju3JR=MbmZkV7>mtG1L!2Q)ViSv~(O^{gPP z6n9(w%nlwP2+j5(1f{-zj9zu%BQ~#+wl_jJE|}HFjF&y9pLh|B<@~q)kTsV8-22h@ zCtcqqXu3R4qBRq$#TgHO^YuG%_TZ;JoQm4PGdny(H6kHE@Hk`9aY71*`Vl0Z$jsjN zA^NwkX`4%iL7$$HjoT#V-0}0oX<+#7)xJ(nDzIGTv{y+|y84>kdc@=ahVn0*AejP& z%P;;Bf7Vs~;3*I>Dm;AiF{PiNa@408-Q|FYp;$Wl^+-BBx?;Y%$N5_qoSl6D);nbA z?kbr(2Z}`n_YP8Pd{ckUV7#!ejU{N?cf_XeLw<$sC6HjR!*e{7&iak8vZA*((n O0000Px{jY&j7RA_%@HYwvyTze`nhbyv@=ZDYrA;_-y2f<2KP3q%y5M0r9A z;)Mr-pkGLn#ChQl;3>%Iph%6tM1n*J3Go;S#Kj2^P~wgbK81>+)S|eb?GQsekvA3IZqqZskE0zi=R+A;2c0-Xqom zLd-oTO=)@|mkvo%P?`4BAOb8L%JgK`0oCdG#DMH)U|_o)m{lo=5{D6!X0Y~X2*^PW z8WCf0D^I^20mv39e(3;s_jf8BK>34zo9|rdhyL^7RsCP=`G0kP`lo&VonM<20N{`R z4XC1d2`_&Up8qGX89YCySv|p?4qya7H{`_cTtPj-n zDelw*FwUS{+(dw9C!Q)_Q_OBa@Q8J&2ogNv5-^j;o6QsS$w)eztX~{{1o_Bc0eC?H zK#K}_^+(auuTIz7{!$3Pv`Atos{`UVVw@nmyTySZs2Ys!?ofy*My6|jFl{c&UVO|yNDMvo*G z9hL3bQ(TqcPXkIs8lmPnSVhDz``pib$o;byKA(N{mrxBDu~ajI64{=e;3`Wv9|4PT zLiCeI1i)Idd`^CNG%4M6JMF=tak?li8YB3gaK1&lo~B!)en6u^`w^3X_5tq|v60LR zqbmEK|Jh3cAKriP-Js0zy}x()`TXDiB4WWL;8z}1#kfdlWs3eVf=ei}ilH!Q>XGw~ zc6&kw^z9bsmu?CQKLCohJNM3(lrBYmO&lX)6U2D7enn9(u_c&K|MZ81e&a_3kt-Oz z08r#R_8#B+5&-d=Um>(g+pH*N3y2nt9^(vR3*>O0?oZyw){#-y$k}-LjH?*18Cnkz zdc@^O*P@{&COIS)L&N6D3Pef%!gayF|1ak_QMTWHN?$J^LRJ~#5Ft-7nPqsirmbtf z|MNE)+jBBAyS)GS-dzmP{?;!NCB-frvN52Mm_%gTVYegWvIpz1iN8`n&xXl9+kOfz z0bM@_qr@QKK!zGWfJtHgg)d#c_~UPE$Zp=i=7N83O&lUp8B7@IPX}V^VCay3#AXgj zK(`(Gq^G^9eBYZ(T$R3{$d~Yc^v{XU8%%D{GzSH(&v2zhQAFUv{u%>DjGeCHWPPnS zD2^}+;4ouH7!*{Qf8pnOc>e+4`>_=te`7;3-$%C|*$fQHK(Ux3VT1(CL5`a_oKTiHXf;f-}oks)o>k*9}^#Soy;t^vp zahUXpJ`n2~NgSG_7-O-SCkzJEkiPl1czFK-um5;SxM4AEg6RgtkF?DIV}|Al<7yzJ z4hn~V+AwZLR3UaAzZ$5wTV!KtA8n|+hGx5^f3`(Z$LoJ_@<#IJ-$BY0Ha(IC#3rC1 zc1kp|tLhp&Mw|h_j3Xv71j!IHf_T)2??2$v zKVC6B?Fi=q$^^e1s1EihZ!XY!Kr~?XGbB$iUBqXB+*opB8Bg-B*9KK{mrhxZ>~|NKvrG?8|*!q+Jpr{Jx({t7~&>j4NZ!LPO?snT^@ zz)=0hLEm|5uU^{#@>g}f*k-`_BN zup!YDRoI?xY3hdNgERcL#}5&WN~lN5+qZb*^HY{9e&QEEEWtOJ!c9bPm!IR7H!q)ibDv80l9n`K=k7$AR$PhOw`azNhK7q@TRO@ZR~gcZ&-wU)m!yia1NTgOJz+ z)0XK4oTgYv0vZ!$pPQibvDddeduH*{Fnq89G5Ay%*FD3#XSAD+rs=W95b6=X>88S{ zN;c1kA;Q98ttCk;qaShBz**1Yz!GzZJ{j3OT~ppXWc>DKq7_5n2Y$rz@csj4pZhub zl}B8l>EYRn5X$1jE1JzybR22xbI6LT`Z;*!^)pl<^Z{=pHcuGdIi=b5BqPYPky05D_dz zJyPuG&ou}oTNuU-`SKOg*MEwK_aE@l@2i;|EGgf-gFYItr9~T$e^k>)PclGgJ-*Z_ zmBvWh4lp-p3`jdpcs(CM6*>=5VPT;V;#r5y6RQ1^(0aPMp=le;Ng?os`&mq;%v-Lb{jZ>z=M_FusGGM1n;%Opv@N zWTxF|SIq&-{LP=?;r$1E&v8w2c7_=S5U@iALqsFQcBCv*#6Z57(qcMGC*eftdQbbb zqg(gL;Bj?CQ-gLoS-uO{?Jmt`lMO;Uy@#myG(j^rRq#RiBdPR14XrbK`JF&&~yPKds7e_cU|~uw#3d8>eUMN z3vB7woVQcqDqwUwsEiXgk&6k$?j_|>hKSJjp5j(Y`mvlcH`n&`9W?cboDBF3vb{NR zJz(2`rm4wX&ivK^^yLsLg| z8Q*OgcHNCzd)oT+@{1Uiwq28#71EC&f}a_Rsz7>=^bzKke3_w%nX*tD$mYqEg*Jhv z4U~mNhInZd02$&`I0cH_q6+!`6sTQ4O+UEk;Z$KqcP?=AVlKLj0l%=6^BdP;w6{dF z30tmA$cxDuZPU_r9x+!Hd@O~cuoxdPV}ymJSR`m_F87p$y>7mpLgi700A1Ts%}Q(^ zrthL+!tf!hzle5oF)_>Y>%t4**8?_9XquY!$(lZR1i^aC{IG;opj{7i8&6T#DG=L0 z+Xi;Ucf!0}DO%i0Y1*kFX}bW+1UJhV9&aZP86}n(QYIuu7&rb}bOD$Bz%>sD`k^c& zfb;`ZS)n83`_+_){ME*CmvMC0SCqF?ilY?S1iH;gyB-nQ3A>m)kTgou2J(3_rPgy# z*L#YxfMqtt-g!$?Z+6W1JLQr~0S$3C7m6M-hkyHDbA0bEfAC)dCN-CR^78d*CeyYZ zwn!%3FRXf$A$H0z&EtXbPkNemDpamknr0G~PLWN(S3-FKH?MGzQXS2w+>c`Hl&{PA z5=n%&eskpb-d*b7{YNN)@uHA*lw~?q^eBWrVtP+MHV6~cY;K6-3xOYZ3FShe=rOeo zyDLA9xD%oO?_c5g-d+Ci_h5T^j(@Zw`S?CrHMv=CCS)S$x+6^-EG_vWoqAMjcbNAt z1d`AXhJFxOhiP9>T^FV|u}WPBR+n68 z#LVVXYHZpKHcM%?Cs15fK3gWVzClE>MMAQyNJz*IXEeQMbJpPI1<6rK`BF9oR%IqS zA<*@KuJ_C|GK)$(M3T(ms)SfM#5qzsRr%44?CB|P8>su5>gAWN;M{FcjZ_}0T}m@a zag|2h%2RKilP_OETqJzspK*NeF7LcGWA@RP8P7ZNk1im3>|TjIsMvdTpK_j}y%J7a zoK5itoOj5cqw6Qgl(UTDjeW{H6-i+ymb;fB){=^0lz}W5>?%-v_BHy%FyCMDjsH+{ zeD5yd?O$hcw1)-*OG{NHmk@I5r$XUpLY^&fNx+{6qQV9Gn)jZN_Db&@co1U<&l^HJ zlD@QuZv)xQjL;4wV%eTGEI)Bbx`6I%g96<**9hPWOZJHwzK^6RoAZV&7{m&3W*EQS zvH#jW>R%YFhLmCB$!-g=ola}A3kcaXpYr$O5Su1!pFF17o+3sWzV=T!zIT^*zdmQk z1UF9cVq+kgCFGyFjZ}`rD&hk}-;v!Y$ZlkG=NrrzC}$-q;9g6ycQWMAGQ7XRw+&6# zv;V1=ss7wP8v72D}3YGJg-S5 zC387xIVVdivfB$xVldXyK0U#e2^xfQJ}1eAcix&$(0S`CG@EIleCs0zT%4$6mAlG9 z8g_HWwk8f!@hY-?2!`3wKCzE<|MeepeD5xAe{F`88Tsvs_M0bk&rb14PwbUyUSRL! zl*n8pG_%)92-MyS7 zzy8BHsdsEo*BpH2Riqzr&BPj;?G|dNUOOZ@JMs3?qBD!CP#jIidp4P&eIVGHdL_K` z)+D_D+OMJ-nJxB!lwb_T>`p zX;~e5s@n&g{^2`KfL|xG>@N=Eh5Fu1B8^!Im9V8q)^OAiVgX}9`*Sn zmUmtOASp83MfX$fy^J8#n@13o^7ak%v;`lrX)$qoBZM0nRe6Kqq$8gH3o!Ol5U!np zU7z7htGc!~{K3DRnpz1|`$wooBm@W^XDm95Naj#KfLJsJs_*$U-M7yG==jH9DZ!D@^2?d9_>70BiV9PUk{ktZDu zD=OVh?G((($RaU-V|dgvw}y>}atLT@a9)5w`|rMdY5#ObY`yMYBrwxR5Q77}4a_r# zM=(zYX4XuPx$s!2paR5*==lTSn^lXF>!B5abK?p(&X&K2L#+fGZ&(0~Cd4F%7_sKJ2b+=!{5geXK0OX4TAdaZ4 zUVrnfniB8$lJ#tij3a#*&lSP-Ee|*601U2vZ-`_{`1euo4?ApiN33UK;t0g+{jcI_ zu5^9Uo;}nvqhMyC%bXiemY8|&p10cA*{S+?JRYkUHN`g_BID*015dgw?5RAGA z-?2cEt`K=fmM#JC{5FN_X(-PKCkq_6i8R3fU|PF~(ntWS{FO!=RSh$;3)jQ*+ZL|- zq5q27D5%dY^2|1(rpR~#N}EVB{TeR4S90LD0p1&w)_1Uhwp)94uhHvcLt=|ty9nR; z@MQq+=K%oWWC6&Hf-+n-%*>soOM6ys+yH$K!pY)Ox8;i>OP6f7_OwsW3E^bHVA#QN zcXR{q_!2uxExN`qk!LnQy}lU%MDg~gFc;(SMDjG(V6R_w+W>=M2g;YpL>VNGy94hD m168w6N5<3~U7WF=jrj-ac=GZ3N7d8-0000Px$cu7P-R5*>Dlff&4aTv!x??P#ri!I_!vy$T79K^+8NhzZJ16&<;M{;!FPgq(? zPRM~A#L>Y)l4)r(!)s>St8MREH0pJF*XG@9d{4j2^Xc<^pMIQp_cY(cy2d}#aDNw} zf5gRhqfg6!W|%)-6#fDb0QNn8ZD4MQo2?4~QtwLLAQM1P36s2v17LG@Vr_0?-{Y@q zH50(f*_x2NjuBMC*akbwK&zum+QP86D#)Zif=U>mXrjkG=B{m^Td0K`0B`TGJ#nRM@|8axfJ_$>U88w$DbQ>Tl zr^;pw25KP-P&WKk_`uR)u4q#A)^^dVl>*`k1ySrWm>3Ky=mNi<6^6(&;P?$yvLDpMBWcMI4fo oNZ^{}t6c1$?w?oMe(5Q{0X<);xt*rODgXcg07*qoM6N<$g3cGttpET3 literal 0 HcmV?d00001 diff --git a/anotherworld/src/main/resources/fabric.mod.json b/anotherworld/src/main/resources/fabric.mod.json index ad4f121..b382ae1 100644 --- a/anotherworld/src/main/resources/fabric.mod.json +++ b/anotherworld/src/main/resources/fabric.mod.json @@ -30,13 +30,14 @@ "minecraft": "~26.2", "java": ">=25", "fabric-api": ">=0.154.2+26.2", - "ambiance": "*" + "ambiance": "*", + "sanctuary": ">=0.0.0-alpha.117" }, "custom": { "sanctuary26": { "system_type": "gameplay-system", "namespace": "anotherworld", - "data_version": 1, + "data_version": 2, "network_protocol": 1, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", diff --git a/anotherworld/src/test/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRulesSmoke.java b/anotherworld/src/test/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRulesSmoke.java new file mode 100644 index 0000000..a8bf466 --- /dev/null +++ b/anotherworld/src/test/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRulesSmoke.java @@ -0,0 +1,26 @@ +package fr.koka99cab.sanctuary26.anotherworld.vault; + +public final class VaultRulesSmoke { + private VaultRulesSmoke() { + } + + public static void main(String[] args) { + require(VaultRules.STRUCTURE_WIDTH == 3, "vault width"); + require(VaultRules.STRUCTURE_BLOCKS == 9, "vault footprint"); + require(VaultRules.STORAGE_SLOTS == 27, "vault capacity"); + require(VaultRules.RAID_DURATION_TICKS == 1_200, "raid duration"); + require(VaultRules.RAID_PROTECTION_TICKS == 1_728_000L, "raid protection"); + require(VaultRules.raidLoot(0) == 0, "empty loot"); + require(VaultRules.raidLoot(1) == 1, "minimum loot"); + require(VaultRules.raidLoot(5) == 1, "exact 20 percent"); + require(VaultRules.raidLoot(64) == 13, "rounded 20 percent"); + require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730, "bounded large balance"); + require(VaultRules.progressPercent(0) == 0, "initial progress"); + require(VaultRules.progressPercent(600) == 50, "half progress"); + require(VaultRules.progressPercent(1_200) == 100, "complete progress"); + } + + private static void require(boolean condition, String contract) { + if (!condition) throw new IllegalStateException("Invalid vault contract: " + contract); + } +} diff --git a/build.gradle b/build.gradle index 9a26495..5ff201d 100644 --- a/build.gradle +++ b/build.gradle @@ -1815,7 +1815,7 @@ tasks.register("verifyWorkspace") { || terramixMigration.compatibility?.new_registry_ids != 100 || terramixMigration.compatibility?.worldgen_changed != true || terramixMigration.compatibility?.save_migration_required != false - || anotherWorldManifest?.custom?.sanctuary26?.data_version != 1 + || anotherWorldManifest?.custom?.sanctuary26?.data_version != 2 || anotherWorldManifest?.custom?.sanctuary26?.network_protocol != 1 || sanctuaryManifest?.custom?.sanctuary26?.data_version < 9 ) { @@ -3428,7 +3428,7 @@ tasks.register("verifyProgressivePauseMenuRelease") { "sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))] } if (rootProject.pack_version != "26.2.0-alpha.201" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || rootProject.onlyfun_version != "0.0.0-alpha.19" || 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"] @@ -3478,7 +3478,7 @@ tasks.register("verifyMirrorLavenderDrawerRelease") { "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/registry/RedStonerRegistries.java").text def drawerRecipe = file("redstoner/src/main/resources/data/redstoner/recipe/drawer.json") if (rootProject.pack_version != "26.2.0-alpha.201" - || rootProject.anotherworld_version != "0.0.0-alpha.49" + || rootProject.anotherworld_version != "0.0.0-alpha.50" || rootProject.redstoner_version != "0.0.0-alpha.10" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.191" @@ -3586,6 +3586,7 @@ project(":itsalive") { project(":anotherworld") { dependencies { implementation project(":ambiance") + implementation project(":sanctuary") } } @@ -4124,7 +4125,7 @@ tasks.register("verifyShopPricingLayoutRelease") { def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) if (rootProject.pack_version != "26.2.0-alpha.201" || rootProject.onlyfun_version != "0.0.0-alpha.19" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || 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" @@ -4218,7 +4219,7 @@ tasks.register("verifyShopCsvBossGatesRelease") { def offerCatalog = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text if (rootProject.pack_version != "26.2.0-alpha.201" || rootProject.onlyfun_version != "0.0.0-alpha.19" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || 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"] @@ -4310,7 +4311,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.201" || rootProject.itsalive_version != "0.0.0-alpha.30" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || 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"] @@ -4410,7 +4411,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.201" || rootProject.itsalive_version != "0.0.0-alpha.30" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || 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" @@ -4530,7 +4531,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.201" - || rootProject.sanctuary_version != "0.0.0-alpha.116" + || rootProject.sanctuary_version != "0.0.0-alpha.117" || 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" @@ -4558,6 +4559,70 @@ tasks.named("verifyWorkspace") { dependsOn tasks.named("verifyQuestRequestEscrowRelease") } +tasks.register("verifySafeVaultFeature") { + group = "verification" + description = "Checks the unreleased 3x3 player safe, titanium drill and Shop currency-provider contract." + dependsOn ":anotherworld:generateAnotherWorldResources" + inputs.file(file("pack/migrations/anotherworld-safe-vault-data-v2.json")) + inputs.file(file("anotherworld/src/main/resources/fabric.mod.json")) + inputs.file(file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java")) + inputs.files(fileTree("anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault") { include "*.java" }) + inputs.files(fileTree("anotherworld/src/main/resources/assets/anotherworld/textures/block/safe_vault") { include "*.png" }) + inputs.file(file("anotherworld/src/main/resources/assets/anotherworld/textures/item/safe_block.png")) + inputs.file(file("anotherworld/src/main/resources/assets/anotherworld/textures/item/titanium_drill.png")) + doLast { + def migration = new JsonSlurper().parse(file("pack/migrations/anotherworld-safe-vault-data-v2.json")) + def manifest = new JsonSlurper().parse(file("anotherworld/src/main/resources/fabric.mod.json")) + def rules = file("anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultRules.java").text + def data = file("anotherworld/src/main/java/fr/koka99cab/sanctuary26/anotherworld/vault/VaultData.java").text + def currencyApi = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java").text + def generated = file("anotherworld/build/generated/resources") + def safeRecipe = new JsonSlurper().parse(new File(generated, "data/anotherworld/recipe/safe_block.json")) + def drillRecipe = new JsonSlurper().parse(new File(generated, "data/anotherworld/recipe/titanium_drill.json")) + def requiredTextures = [ + "block/safe_vault/bottom.png": [48, 48], "block/safe_vault/front.png": [48, 48], + "block/safe_vault/side.png": [48, 48], "block/safe_vault/top.png": [48, 48], + "item/safe_block.png": [16, 16], "item/titanium_drill.png": [16, 16] + ] + def invalidTextures = requiredTextures.findAll { path, size -> + def image = javax.imageio.ImageIO.read(file("anotherworld/src/main/resources/assets/anotherworld/textures/${path}")) + image == null || image.width != size[0] || image.height != size[1] + }.keySet() + if (rootProject.anotherworld_version != "0.0.0-alpha.50" + || rootProject.sanctuary_version != "0.0.0-alpha.117" + || migration.source?.modules != [anotherworld: "0.0.0-alpha.49", sanctuary: "0.0.0-alpha.116"] + || migration.target?.modules != [anotherworld: "0.0.0-alpha.50", sanctuary: "0.0.0-alpha.117"] + || migration.safe_vault?.footprint != "3x3_horizontal" + || migration.safe_vault?.blocks_required != 9 + || migration.safe_vault?.accepted_items != ["anotherworld:ruby", "anotherworld:sapphire", "minecraft:emerald"] + || migration.safe_vault?.shop_access_when_structure_unloaded != true + || migration.raid?.continuous_use_ticks != 1200 + || migration.raid?.loot_percent != 20 + || migration.raid?.protection_ticks_after_success != 1728000 + || migration.compatibility?.anotherworld_source_data_version != 1 + || migration.compatibility?.anotherworld_target_data_version != 2 + || migration.compatibility?.anotherworld_network_protocol != 1 + || migration.compatibility?.sanctuary_network_protocol != 21 + || manifest.depends?.sanctuary != ">=0.0.0-alpha.117" + || manifest.custom?.sanctuary26?.data_version != 2 + || !file("build.gradle").text.contains('implementation project(":sanctuary")') + || !currencyApi.contains("public interface Provider") + || !currencyApi.contains("countAdditional") || !currencyApi.contains("removeAdditional") + || !rules.contains("STRUCTURE_WIDTH = 3") || !rules.contains("RAID_DURATION_TICKS = 60 * 20") + || !rules.contains("RAID_LOOT_PERCENT = 20") || !rules.contains("24L * 60L * 60L * 20L") + || !data.contains('SAVE_KEY = "safe_vaults"') || !data.contains("DATA_VERSION = 1") + || safeRecipe.pattern != ["GGG", "GCG", "GGG"] + || drillRecipe.pattern != ["TTT", "RPR", "FDF"] + || !invalidTextures.isEmpty()) { + throw new GradleException("The safe-vault feature contract is incomplete: textures=${invalidTextures}") + } + } +} + +tasks.named("verifyWorkspace") { + dependsOn tasks.named("verifySafeVaultFeature") +} + tasks.named("check") { dependsOn tasks.named("verifyWorkspace") dependsOn tasks.named("verifyTransport") diff --git a/gradle.properties b/gradle.properties index 589b2cb..418ffbd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ ambiance_version=0.0.0-alpha.12 ambiance_lifecycle=active redstoner_version=0.0.0-alpha.10 redstoner_lifecycle=active -anotherworld_version=0.0.0-alpha.49 +anotherworld_version=0.0.0-alpha.50 anotherworld_lifecycle=active itsalive_version=0.0.0-alpha.30 itsalive_lifecycle=active @@ -28,7 +28,7 @@ iliketomoveit_version=0.0.0-alpha.13 iliketomoveit_lifecycle=active onlyfun_version=0.0.0-alpha.19 onlyfun_lifecycle=active -sanctuary_version=0.0.0-alpha.116 +sanctuary_version=0.0.0-alpha.117 sanctuary_lifecycle=active pack_version=26.2.0-alpha.201 maven_group=fr.koka99cab.sanctuary26 diff --git a/pack/migrations/anotherworld-safe-vault-data-v2.json b/pack/migrations/anotherworld-safe-vault-data-v2.json new file mode 100644 index 0000000..ab77012 --- /dev/null +++ b/pack/migrations/anotherworld-safe-vault-data-v2.json @@ -0,0 +1,76 @@ +{ + "source": { + "pack_version": "26.2.0-alpha.201", + "modules": { + "anotherworld": "0.0.0-alpha.49", + "sanctuary": "0.0.0-alpha.116" + } + }, + "target": { + "pack_version": "26.2.0-alpha.201", + "modules": { + "anotherworld": "0.0.0-alpha.50", + "sanctuary": "0.0.0-alpha.117" + } + }, + "safe_vault": { + "owner": "anotherworld", + "shop_currency_owner": "sanctuary", + "footprint": "3x3_horizontal", + "blocks_required": 9, + "single_owner_required": true, + "maximum_active_vaults_per_player": 1, + "storage_slots": 27, + "accepted_items": [ + "anotherworld:ruby", + "anotherworld:sapphire", + "minecraft:emerald" + ], + "shop_access_when_structure_unloaded": true, + "storage_provider_api": "SanctuaryCurrencyStorage", + "save_key": "anotherworld_safe_vaults", + "save_data_version": 1 + }, + "raid": { + "tool": "anotherworld:titanium_drill", + "continuous_use_ticks": 1200, + "owner_must_be_online": true, + "loot_percent": 20, + "drill_consumed_on_success": true, + "protection_ticks_after_success": 1728000, + "server_authoritative": true + }, + "recipes": { + "safe_block": { + "pattern": ["GGG", "GCG", "GGG"], + "ingredients": { + "G": "minecraft:gold_block", + "C": "minecraft:chest" + } + }, + "titanium_drill": { + "pattern": ["TTT", "RPR", "FDF"], + "ingredients": { + "T": "anotherworld:titanium_ingot", + "R": "minecraft:redstone", + "P": "minecraft:piston", + "F": "minecraft:iron_block", + "D": "minecraft:diamond_pickaxe" + } + } + }, + "compatibility": { + "anotherworld_source_data_version": 1, + "anotherworld_target_data_version": 2, + "anotherworld_network_protocol": 1, + "sanctuary_data_version": 11, + "sanctuary_network_protocol": 21, + "new_registry_ids": [ + "anotherworld:safe_block", + "anotherworld:safe_vault", + "anotherworld:titanium_drill" + ], + "existing_world_reset_required": false, + "pack_release_updated": false + } +} diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index af53f03..8f6c08c 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -2893,7 +2893,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || 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 @@ -2931,7 +2931,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || 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] @@ -2962,7 +2962,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || migration.target?.pack_version != "26.2.0-alpha.195" || migration.target?.modules?.sanctuary != "0.0.0-alpha.111" || migration.compatibility?.sanctuary_data_version != 11 @@ -2993,7 +2993,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || migration.target?.modules?.sanctuary != "0.0.0-alpha.112" || migration.mailbox?.id != "sanctuary:delivery_box" || migration.mailbox?.horizontal_facing != true @@ -3021,7 +3021,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || !transfer.contains("shouldAppendOverflow") || !transfer.contains("isVanillaStorageRange") || !transfer.contains("hotbarStart") @@ -3067,7 +3067,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || migration.target?.modules?.sanctuary != "0.0.0-alpha.113" || migration.quest_requests?.save_key != "quest_requests" || migration.quest_requests?.data_version != 1 @@ -3125,7 +3125,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || 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" @@ -3194,7 +3194,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.116" + if (project.version.toString() != "0.0.0-alpha.117" || 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" diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java new file mode 100644 index 0000000..14ae253 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryCurrencyStorage.java @@ -0,0 +1,61 @@ +package fr.koka99cab.sanctuary26.sanctuary.api.inventory; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.Item; + +/** + * Public extension point for player-owned currency stores outside the Sanctuary inventory. + * + *

Providers keep ownership of their data and must validate every mutation on the server. + * Sanctuary only aggregates their balances for Shop and Black Market transactions.

+ */ +public final class SanctuaryCurrencyStorage { + private static final Map PROVIDERS = new LinkedHashMap<>(); + + private SanctuaryCurrencyStorage() { + } + + public static synchronized void register(Identifier id, Provider provider) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(provider, "provider"); + if (PROVIDERS.putIfAbsent(id, provider) != null) { + throw new IllegalStateException("Currency storage provider already registered: " + id); + } + } + + public static int countAdditional(ServerPlayer player, Item item) { + if (player == null || item == null) return 0; + long count = 0L; + for (Provider provider : snapshot()) { + count += Math.max(0, provider.count(player, item)); + if (count >= Integer.MAX_VALUE) return Integer.MAX_VALUE; + } + return (int) count; + } + + public static int removeAdditional(ServerPlayer player, Item item, int amount) { + if (player == null || item == null || amount <= 0) return 0; + int remaining = amount; + for (Provider provider : snapshot()) { + if (remaining <= 0) break; + int removed = Math.clamp(provider.remove(player, item, remaining), 0, remaining); + remaining -= removed; + } + return amount - remaining; + } + + private static synchronized java.util.List snapshot() { + return java.util.List.copyOf(PROVIDERS.values()); + } + + public interface Provider { + int count(ServerPlayer player, Item item); + + /** Returns the exact number removed, between zero and {@code amount}. */ + int remove(ServerPlayer player, Item item, int amount); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java index 26d790a..0161fa8 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java @@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.sanctuary.shop; import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView; +import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryCurrencyStorage; import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketCreatePayload; import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketEditorPayload; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryOverflowAccess; @@ -354,7 +355,7 @@ public final class BlackMarketService { private static int countCurrency(ServerPlayer player, Item item) { return countPlayerItem(player, item); } static int countPlayerItem(ServerPlayer player, Item item) { if (item == null) return 0; - long count = 0L; + long count = SanctuaryCurrencyStorage.countAdditional(player, item); for (ItemStack stack : currencyStacks(player)) { if (stack.is(item)) count += stack.getCount(); } @@ -372,6 +373,7 @@ public final class BlackMarketService { } player.getInventory().setChanged(); SanctuaryOverflowAccess.of(player).setChanged(); + if (remaining > 0) SanctuaryCurrencyStorage.removeAdditional(player, item, remaining); } /** -- 2.54.0