From f283c1705bbe9d2bf782ad08a50a6fd5f93c57c1 Mon Sep 17 00:00:00 2001 From: koka Date: Mon, 31 Aug 2026 05:40:35 +0200 Subject: [PATCH] feat: add Notch Alpha encounter --- CHANGELOG.md | 11 + README.md | 2 + build.gradle | 167 ++++-- gradle.properties | 8 +- itsalive/README.md | 10 +- itsalive/build.gradle | 21 +- .../sanctuary26/itsalive/ItsAliveMod.java | 4 +- .../itsalive/api/notch/NotchCombatApi.java | 46 ++ .../itsalive/client/ItsAliveClient.java | 5 + .../itsalive/client/model/NotchModel.java | 21 + .../client/renderer/NotchRenderer.java | 45 ++ .../renderer/state/NotchRenderState.java | 8 + .../itsalive/entity/notch/NotchEntity.java | 495 ++++++++++++++++++ .../entity/notch/NotchFightRules.java | 49 ++ .../entity/notch/NotchResidentService.java | 156 ++++++ .../registry/ItsAliveEntityTypes.java | 21 +- .../resources/assets/itsalive/lang/en_us.json | 4 +- .../resources/assets/itsalive/lang/fr_fr.json | 4 +- .../resources/assets/itsalive/lang/ru_ru.json | 4 +- .../assets/itsalive/textures/entity/notch.png | Bin 0 -> 1128 bytes itsalive/src/main/resources/fabric.mod.json | 4 +- .../entity/notch/NotchFightRulesSmoke.java | 34 ++ .../masterkey/command/MasterKeyCommands.java | 38 ++ .../assets/masterkey/lang/en_us.json | 6 +- .../assets/masterkey/lang/fr_fr.json | 6 +- .../assets/masterkey/lang/ru_ru.json | 6 +- masterkey/src/main/resources/fabric.mod.json | 4 +- ouch/README.md | 11 +- ouch/build.gradle | 2 +- .../sanctuary26/ouch/client/OuchClient.java | 3 + .../ouch/entity/NotchFireballEntity.java | 61 +++ .../ouch/notch/OuchNotchCombat.java | 111 ++++ .../ouch/registry/OuchRegistries.java | 2 + ouch/src/main/resources/fabric.mod.json | 3 +- sanctuary/ALPHA.md | 15 + sanctuary/build.gradle | 33 +- .../sanctuary26/sanctuary/SanctuaryMod.java | 2 +- .../sanctuary/api/atlas/AtlasLandmark.java | 2 +- .../api/atlas/SanctuaryAtlasLandmarks.java | 23 + .../api/notch/SanctuaryNotchMilestone.java | 99 ++++ .../client/SanctuaryAtlasMarkerLayer.java | 1 + .../sanctuary/data/NotchVictoryData.java | 60 +++ .../sanctuary/data/NotchVictoryState.java | 77 +++ .../SanctuaryProgressionService.java | 24 + .../assets/sanctuary/lang/en_us.json | 4 +- .../assets/sanctuary/lang/fr_fr.json | 4 +- .../assets/sanctuary/lang/ru_ru.json | 4 +- sanctuary/src/main/resources/fabric.mod.json | 2 +- .../data/NotchVictoryStateSmoke.java | 40 ++ 49 files changed, 1678 insertions(+), 84 deletions(-) create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/api/notch/NotchCombatApi.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/model/NotchModel.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/NotchRenderer.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/state/NotchRenderState.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchEntity.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRules.java create mode 100644 itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchResidentService.java create mode 100644 itsalive/src/main/resources/assets/itsalive/textures/entity/notch.png create mode 100644 itsalive/src/test/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRulesSmoke.java create mode 100644 ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/entity/NotchFireballEntity.java create mode 100644 ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/notch/OuchNotchCombat.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java create mode 100644 sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java create mode 100644 sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index aa824f8..2aa0291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve - Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46. - Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées. +## Non distribué + +Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **Sanctuary `0.0.0-alpha.126`** et **MasterKey `0.0.0-alpha.9`**. Le pack public reste temporairement en `26.2.0-alpha.223` jusqu'à la publication. + +- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ; +- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ; +- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ; +- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve un drapeau de récompense unique pour la suite ; +- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ; +- migre les données It's Alive de v3 à v4 et Sanctuary de v11 à v12, sans changer les protocoles réseau ni le pack public. + ## `26.2.0-alpha.223` Module modifié : **Sanctuary `0.0.0-alpha.125`**. Les neuf autres modules conservent leur version issue de l'alpha.222. diff --git a/README.md b/README.md index e3ea378..7d8e099 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ L’alpha.223 ajoute le tri natif des rangées de stockage par clic molette sans L’alpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions d’IDs d’items et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`. +Le lot non distribué ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. + L’alpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée. L’alpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées d’inventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans l’ordre de découverte avant de passer au suivant. diff --git a/build.gradle b/build.gradle index a6f628f..5f4bc78 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ import groovy.json.JsonSlurper +import java.security.MessageDigest plugins { id "base" @@ -2875,7 +2876,91 @@ tasks.register("verifyCavernsGeology") { } } +tasks.register("verifyNotchEncounter") { + group = "verification" + description = "Checks NOTCH-01 through NOTCH-03 ownership, persistence, combat and global victory contracts." + inputs.files(fileTree("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive") { + include "**/*Notch*.java" + }) + inputs.files(fileTree("ouch/src/main/java/fr/koka99cab/sanctuary26/ouch") { + include "**/*Notch*.java" + }) + inputs.files(fileTree("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary") { + include "**/*Notch*.java", "**/SanctuaryProgressionService.java", "**/SanctuaryAtlasLandmarks.java" + }) + inputs.file(file("itsalive/src/main/resources/assets/itsalive/textures/entity/notch.png")) + inputs.files(["itsalive", "sanctuary", "masterkey"].collectMany { id -> + ["fr_fr", "en_us", "ru_ru"].collect { locale -> + file("${id}/src/main/resources/assets/${id}/lang/${locale}.json") + } + }) + inputs.files(["itsalive", "ouch", "sanctuary", "masterkey"].collect { + file("${it}/src/main/resources/fabric.mod.json") + }) + inputs.file(file("masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java")) + + doLast { + def manifests = ["itsalive", "ouch", "sanctuary", "masterkey"].collectEntries { id -> + [(id): new JsonSlurper().parse(file("${id}/src/main/resources/fabric.mod.json"))] + } + def entity = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchEntity.java").text + def resident = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchResidentService.java").text + def registry = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveEntityTypes.java").text + def combat = file("ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/notch/OuchNotchCombat.java").text + def fireball = file("ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/entity/NotchFireballEntity.java").text + def milestone = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java").text + def victory = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java").text + def commands = file("masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java").text + def texture = file("itsalive/src/main/resources/assets/itsalive/textures/entity/notch.png") + def textureHash = MessageDigest.getInstance("SHA-256").digest(texture.bytes).encodeHex().toString() + if (rootProject.itsalive_version != "0.0.0-alpha.37" + || rootProject.ouch_version != "0.0.0-alpha.11" + || rootProject.sanctuary_version != "0.0.0-alpha.126" + || rootProject.masterkey_version != "0.0.0-alpha.9" + || rootProject.pack_version != "26.2.0-alpha.223" + || manifests.itsalive.custom?.sanctuary26?.data_version != 4 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 12 + || manifests.ouch.depends?.itsalive != ">=0.0.0-alpha.37" + || !registry.contains('Identifier.fromNamespaceAndPath("portals", "notch")') + || !resident.contains("setCanonicalUuid(canonical.getUUID())") + || !resident.contains("resident.discard()") + || !resident.contains("migrateLegacyVictory") + || !entity.contains("MAX_PARTICIPANTS") + || !entity.contains("NotchFightRules.shouldReset") + || entity.count("ExplosionInteraction.BLOCK") != 1 + || !combat.contains("NotchCombatApi.register") + || !combat.contains("case FIREBALL") || !combat.contains("case LIGHTNING") + || combat.contains("ExplosionInteraction.BLOCK") + || !fireball.contains("ExplosionInteraction.NONE") + || !fireball.contains("RETURNED_FIREBALL_DAMAGE") + || !milestone.contains('NOTCH_DEFEATED = SanctuaryMod.id("notch_defeated")') + || !milestone.contains("recordExternalAt") + || !milestone.contains("recordTrusted") + || !victory.contains("initialRewardClaimed") + || !commands.contains('Commands.literal("notch")') + || !commands.contains('Commands.literal("inspect")') + || !commands.contains('Commands.literal("restore")') + || textureHash != "4066eb0e6b35aab5a48ab203e826b2588900516a757be09dacd9c589830d06f4") { + throw new GradleException("The NOTCH-01 through NOTCH-03 contract is incomplete") + } + ["itsalive", "sanctuary", "masterkey"].each { id -> + ["fr_fr", "en_us", "ru_ru"].each { locale -> + def values = new JsonSlurper().parse(file("${id}/src/main/resources/assets/${id}/lang/${locale}.json")) + def required = id == "itsalive" + ? ["entity.itsalive.notch", "boss.itsalive.notch_titan"] + : id == "sanctuary" + ? ["screen.sanctuary.atlas.landmark.boss", "message.sanctuary.notch_defeated"] + : ["command.masterkey.notch.inspect", "command.masterkey.notch.restore.success"] + if (!values.keySet().containsAll(required)) { + throw new GradleException("Missing ${id} Notch translations in ${locale}") + } + } + } + } +} + tasks.named("verifyWorkspace") { + dependsOn tasks.named("verifyNotchEncounter") dependsOn tasks.named("verifyCavernsGeology") } @@ -3438,7 +3523,7 @@ tasks.register("verifyProgressivePauseMenuRelease") { "sanctuary/src/main/resources/assets/sanctuary/lang/${locale}.json"))] } if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || rootProject.onlyfun_version != "0.0.0-alpha.25" || 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"] @@ -3606,6 +3691,7 @@ project(":ouch") { dependencies { implementation project(":anotherworld") implementation project(":ambiance") + implementation project(":itsalive") implementation project(":onlyfun") implementation project(":sanctuary") } @@ -4279,7 +4365,7 @@ tasks.register("verifyShopPricingLayoutRelease") { def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.onlyfun_version != "0.0.0-alpha.25" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || 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" @@ -4323,7 +4409,7 @@ tasks.register("verifyCuisineCropsRelease") { def rice = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/RiceCropBlock.java").text def bean = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/BeanCropBlock.java").text if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.193" || migration.source?.modules != [itsalive: "0.0.0-alpha.26"] @@ -4373,7 +4459,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.223" || rootProject.onlyfun_version != "0.0.0-alpha.25" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || 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"] @@ -4464,8 +4550,8 @@ tasks.register("verifyQuestRequestsCauldronBatchesRelease") { def packwiz = file("pack/packwiz/pack.toml").text def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.itsalive_version != "0.0.0-alpha.37" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || 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"] @@ -4514,7 +4600,7 @@ tasks.register("verifyFermentationBarrelAutomationRelease") { def packwizIndex = file("pack/packwiz/index.toml").text def itsAliveManifest = new JsonSlurper().parse(file("itsalive/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || migration.source?.pack_version != "26.2.0-alpha.197" || migration.target?.pack_version != "26.2.0-alpha.198" || migration.target?.modules != [itsalive: "0.0.0-alpha.30", sanctuary: "0.0.0-alpha.114"] @@ -4530,7 +4616,7 @@ tasks.register("verifyFermentationBarrelAutomationRelease") { || migration.compatibility?.save_migration_required != false || migration.compatibility?.registry_ids_changed != false || migration.distribution?.packwiz_index_refreshed != true - || itsAliveManifest.custom?.sanctuary26?.data_version != 3 + || itsAliveManifest.custom?.sanctuary26?.data_version != 4 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || release.pack_version != "26.2.0-alpha.223" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack" @@ -4564,8 +4650,8 @@ tasks.register("verifyQuestRequestContractsRelease") { def packwizIndex = file("pack/packwiz/index.toml").text def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.itsalive_version != "0.0.0-alpha.37" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || 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" @@ -4620,7 +4706,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") { def rootBuild = file("build.gradle").text if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.ambiance_version != "0.0.0-alpha.22" - || rootProject.ouch_version != "0.0.0-alpha.10" + || rootProject.ouch_version != "0.0.0-alpha.11" || migration.source?.pack_version != "26.2.0-alpha.199" || migration.source?.modules != [ambiance: "0.0.0-alpha.11", ouch: "0.0.0-alpha.8"] || migration.target?.pack_version != "26.2.0-alpha.200" @@ -4685,7 +4771,7 @@ tasks.register("verifyQuestRequestEscrowRelease") { def packwizIndex = file("pack/packwiz/index.toml").text def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || 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" @@ -4697,7 +4783,7 @@ tasks.register("verifyQuestRequestEscrowRelease") { || migration.quest_request_escrow?.kill_progress_automatic != true || migration.quest_request_escrow?.reward_delivery_to_claimant_mailbox != true || migration.compatibility?.sanctuary_target_network_protocol != 21 - || sanctuaryManifest.custom?.sanctuary26?.data_version != 11 + || sanctuaryManifest.custom?.sanctuary26?.data_version != 12 || sanctuaryManifest.custom?.sanctuary26?.network_protocol != 22 || release.pack_version != "26.2.0-alpha.223" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack" @@ -4740,9 +4826,9 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") { "sanctuary/src/main/resources/fabric.mod.json")) if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.anotherworld_version != "0.0.0-alpha.55" - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || rootProject.iliketomoveit_version != "0.0.0-alpha.16" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.source?.pack_version != "26.2.0-alpha.201" || migration.source?.modules != [ itsalive: "0.0.0-alpha.30", @@ -4772,11 +4858,11 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") { || migration.compatibility?.sanctuary_network_protocol != 21 || migration.compatibility?.save_migration_required != false || migration.compatibility?.registry_ids_changed != false - || itsAliveManifest.custom?.sanctuary26?.data_version != 3 + || itsAliveManifest.custom?.sanctuary26?.data_version != 4 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || moveItManifest.custom?.sanctuary26?.data_version != 3 || moveItManifest.custom?.sanctuary26?.network_protocol != 5 - || sanctuaryManifest.custom?.sanctuary26?.data_version != 11 + || sanctuaryManifest.custom?.sanctuary26?.data_version != 12 || sanctuaryManifest.custom?.sanctuary26?.network_protocol != 22 || release.pack_version != "26.2.0-alpha.223" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack" @@ -4900,7 +4986,7 @@ tasks.register("verifySafeVaultFeature") { }.keySet() if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.anotherworld_version != "0.0.0-alpha.55" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.schema_version != 1 || migration.source?.pack_version != "26.2.0-alpha.201" || migration.target?.pack_version != "26.2.0-alpha.202" @@ -5389,7 +5475,7 @@ tasks.register("verifyCanapliaFoundation") { def visualApiSource = file( "ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/api/visual/AmbianceVisualEffectApi.java").text if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || rootProject.ambiance_version != "0.0.0-alpha.22" || migration.issue != 35 || migration.source?.fabric_id != "canaplia" @@ -5432,8 +5518,8 @@ tasks.register("verifyCanapliaFoundation") { || migration.distribution?.packwiz_index_refreshed != false || itsAliveManifest.provides != ["canaplia"] || itsAliveManifest.depends?.ambiance != ">=0.0.0-alpha.14" - || itsAliveManifest.depends?.sanctuary != ">=0.0.0-alpha.117" - || itsAliveManifest.custom?.sanctuary26?.data_version != 3 + || itsAliveManifest.depends?.sanctuary != ">=0.0.0-alpha.126" + || itsAliveManifest.custom?.sanctuary26?.data_version != 4 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || ambianceManifest.custom?.sanctuary26?.data_version != 2 || ambianceManifest.custom?.sanctuary26?.network_protocol != 6 @@ -5568,7 +5654,7 @@ tasks.register("verifyCanapliaGameplay") { || migration.compatibility?.save_reset_required != false || migration.compatibility?.historical_registry_ids_changed != false || migration.compatibility?.pack_release_updated != false - || itsAliveManifest.custom?.sanctuary26?.data_version != 3 + || itsAliveManifest.custom?.sanctuary26?.data_version != 4 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || ambianceManifest.custom?.sanctuary26?.network_protocol != 6 || discovery.schema_version != 1 || discovery.sources.size() > 128 @@ -5642,7 +5728,7 @@ tasks.register("verifyCanapliaAlpha209Release") { onlyfun: "0.0.0-alpha.20"] if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.ambiance_version != "0.0.0-alpha.22" - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || rootProject.onlyfun_version != "0.0.0-alpha.25" || migration.source?.pack_version != "26.2.0-alpha.207" || migration.source?.modules != expectedSource @@ -5759,10 +5845,10 @@ tasks.register("verifyAlpha210Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || rootProject.onlyfun_version != "0.0.0-alpha.25" || rootProject.iliketomoveit_version != currentModules.iliketomoveit - || rootProject.itsalive_version != "0.0.0-alpha.36" + || rootProject.itsalive_version != "0.0.0-alpha.37" || migration.source?.pack_version != "26.2.0-alpha.209" || migration.source?.modules != expectedSource || migration.target?.pack_version != "26.2.0-alpha.210" @@ -5796,13 +5882,13 @@ tasks.register("verifyAlpha210Release") { || migration.distribution?.jei?.target != "30.28.0.193" || migration.distribution?.jei?.modrinth_version_id != "qaCQ7LGO" || migration.distribution?.jei?.packwiz_and_prism_synchronized != true - || manifests.sanctuary.custom?.sanctuary26?.data_version != 11 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 12 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22 || manifests.onlyfun.custom?.sanctuary26?.data_version != 2 || manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3 || manifests.iliketomoveit.custom?.sanctuary26?.data_version != 3 || manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5 - || manifests.itsalive.custom?.sanctuary26?.data_version != 3 + || manifests.itsalive.custom?.sanctuary26?.data_version != 4 || manifests.itsalive.custom?.sanctuary26?.network_protocol != 4 || release.pack_version != "26.2.0-alpha.223" || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.223.mrpack" @@ -5858,8 +5944,10 @@ tasks.register("verifyAlpha211Release") { onlyfun: "0.0.0-alpha.25", ambiance: "0.0.0-alpha.22", iliketomoveit: "0.0.0-alpha.16"] - def workspaceModules = currentModules + def workspaceModules = currentModules + [ + sanctuary: "0.0.0-alpha.126", itsalive: "0.0.0-alpha.37", ouch: "0.0.0-alpha.11"] def expectedDataVersions = [ambiance: 2, itsalive: 3, onlyfun: 2, ouch: 1, sanctuary: 11] + def workspaceDataVersions = [ambiance: 2, itsalive: 4, onlyfun: 2, ouch: 1, sanctuary: 12] def expectedProtocols = [ambiance: 6, itsalive: 4, onlyfun: 3, ouch: 1, sanctuary: 22] def manifests = expectedTarget.collectEntries { id, version -> [(id): new JsonSlurper().parse(file("${id}/src/main/resources/fabric.mod.json"))] @@ -5904,7 +5992,7 @@ tasks.register("verifyAlpha211Release") { || migration.compatibility?.registry_ids_added != expectedIds || migration.compatibility?.data_versions != expectedDataVersions || migration.compatibility?.network_protocols != expectedProtocols - || manifestDataVersions != expectedDataVersions + || manifestDataVersions != workspaceDataVersions || manifestProtocols != expectedProtocols || migration.distribution?.pack_release_updated != true || migration.distribution?.packwiz_index_refreshed != true @@ -5956,7 +6044,7 @@ tasks.register("verifyAlpha212Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.source?.pack_version != "26.2.0-alpha.211" || migration.source?.modules != [sanctuary: "0.0.0-alpha.119"] || migration.target?.pack_version != "26.2.0-alpha.212" @@ -5974,7 +6062,7 @@ tasks.register("verifyAlpha212Release") { || migration.distribution?.packwiz_index_refreshed != true || migration.distribution?.prism_modrinth_index_updated != true || migration.distribution?.shared_client_server_mrpack != true - || manifest.custom?.sanctuary26?.data_version != 11 + || manifest.custom?.sanctuary26?.data_version != 12 || manifest.custom?.sanctuary26?.network_protocol != 22 || !minecraftMixin.contains('method = "pickBlockOrEntity"') || release.pack_version != "26.2.0-alpha.223" @@ -6449,7 +6537,7 @@ tasks.register("verifyAlpha218Release") { || currentModules.findAll { id, version -> id != "redstoner" && id != "sanctuary" }.any { id, version -> rootProject.property("${id}_version") != version } - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.source?.pack_version != "26.2.0-alpha.217" || migration.source?.modules != sourceModules || migration.target?.pack_version != "26.2.0-alpha.218" @@ -6473,7 +6561,7 @@ tasks.register("verifyAlpha218Release") { || manifests.redstoner.custom?.sanctuary26?.network_protocol != 7 || manifests.onlyfun.custom?.sanctuary26?.data_version != 2 || manifests.onlyfun.custom?.sanctuary26?.network_protocol != 3 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 11 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 12 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22 || !rules.contains("MAX_CONTAINERS = 128") || !rules.contains("PAGE_SIZE = 54") @@ -6533,7 +6621,7 @@ tasks.register("verifyAlpha219Release") { sanctuary: "0.0.0-alpha.122", iliketomoveit: "0.0.0-alpha.16", ambiance: "0.0.0-alpha.21"] def currentModules = targetModules + [ ambiance: "0.0.0-alpha.22", sanctuary: "0.0.0-alpha.125"] - def workspaceModules = currentModules + def workspaceModules = currentModules + [sanctuary: "0.0.0-alpha.126"] def requiredJars = currentModules.collect { id, version -> "file = \"mods/${id}-${version}.jar\"" } @@ -6573,7 +6661,7 @@ tasks.register("verifyAlpha219Release") { || migration.compatibility?.iliketomoveit_network_protocol != 5 || migration.compatibility?.ambiance_data_version != 2 || migration.compatibility?.ambiance_network_protocol != 6 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 11 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 12 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22 || manifests.iliketomoveit.custom?.sanctuary26?.data_version != 3 || manifests.iliketomoveit.custom?.sanctuary26?.network_protocol != 5 @@ -6858,7 +6946,7 @@ tasks.register("verifyAlpha222Release") { } if (rootProject.pack_version != "26.2.0-alpha.223" || rootProject.redstoner_version != "0.0.0-alpha.13" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.source?.pack_version != "26.2.0-alpha.221" || migration.source?.modules != [ redstoner: "0.0.0-alpha.12", sanctuary: "0.0.0-alpha.122"] @@ -6900,7 +6988,7 @@ tasks.register("verifyAlpha222Release") { || migration.distribution?.jei?.packwiz_and_prism_synchronized != true || manifests.redstoner.custom?.sanctuary26?.data_version != 3 || manifests.redstoner.custom?.sanctuary26?.network_protocol != 7 - || manifests.sanctuary.custom?.sanctuary26?.data_version != 11 + || manifests.sanctuary.custom?.sanctuary26?.data_version != 12 || manifests.sanctuary.custom?.sanctuary26?.network_protocol != 22 || !rules.contains("MAX_SEARCH_LENGTH = 32") || !rules.contains("compactCount(int count)") @@ -7016,7 +7104,7 @@ tasks.register("verifyAlpha223Release") { it.path == "mods/jei-26.2-fabric-30.29.0.198.jar" } if (rootProject.pack_version != "26.2.0-alpha.223" - || rootProject.sanctuary_version != "0.0.0-alpha.125" + || rootProject.sanctuary_version != "0.0.0-alpha.126" || migration.source?.pack_version != "26.2.0-alpha.222" || migration.source?.modules != [sanctuary: "0.0.0-alpha.124"] || migration.target?.pack_version != "26.2.0-alpha.223" @@ -7052,7 +7140,7 @@ tasks.register("verifyAlpha223Release") { || migration.distribution?.jei?.target != "30.29.0.198" || migration.distribution?.jei?.modrinth_version_id != "67Dvn8sL" || migration.distribution?.jei?.packwiz_and_prism_synchronized != true - || manifest.custom?.sanctuary26?.data_version != 11 + || manifest.custom?.sanctuary26?.data_version != 12 || manifest.custom?.sanctuary26?.network_protocol != 22 || !inventorySort.contains("COOLDOWN_TICKS = 8") || !inventorySort.contains("if (row == 0) continue") @@ -7090,7 +7178,6 @@ tasks.register("verifyAlpha223Release") { || jeiFiles[0].hashes?.sha1 != "652e964c71a699fdfdd96cda265ea703b13c6ef4" || jeiFiles[0].hashes?.sha512 != "06131c9528549840e07ddc9634e23454cd454380d7749a23d818f0efe0dacc02874e967ceb74a24ee82efb13e0fe2df4026f988a68f54f890586967e91dcaa5b" || !changelog.contains("## `26.2.0-alpha.223`") - || changelog.contains("## Non distribué") || !readme.contains("Version courante du pack distribué : **`26.2.0-alpha.223`**")) { throw new GradleException("The alpha.223 inventory-sort and quest-tracker display release is incomplete") } diff --git a/gradle.properties b/gradle.properties index 8eed805..ed5004b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,17 +18,17 @@ redstoner_version=0.0.0-alpha.13 redstoner_lifecycle=active anotherworld_version=0.0.0-alpha.55 anotherworld_lifecycle=active -itsalive_version=0.0.0-alpha.36 +itsalive_version=0.0.0-alpha.37 itsalive_lifecycle=active -ouch_version=0.0.0-alpha.10 +ouch_version=0.0.0-alpha.11 ouch_lifecycle=active -masterkey_version=0.0.0-alpha.8 +masterkey_version=0.0.0-alpha.9 masterkey_lifecycle=active iliketomoveit_version=0.0.0-alpha.16 iliketomoveit_lifecycle=active onlyfun_version=0.0.0-alpha.25 onlyfun_lifecycle=active -sanctuary_version=0.0.0-alpha.125 +sanctuary_version=0.0.0-alpha.126 sanctuary_lifecycle=active pack_version=26.2.0-alpha.223 maven_group=fr.koka99cab.sanctuary26 diff --git a/itsalive/README.md b/itsalive/README.md index f015991..140bca8 100644 --- a/itsalive/README.md +++ b/itsalive/README.md @@ -2,7 +2,15 @@ It's Alive possède le vivant de Sanctuary. Il enrichit directement le Creeper vanilla et accueille désormais les papillons de Pipallon, les Mooblooms fleuries et les créatures de Real Ghost — baleine volante comprise — sous un référencement unifié `itsalive:*`. -## Version courante — `0.0.0-alpha.36` +## Version courante — `0.0.0-alpha.37` + +## Résident Notch + +- `itsalive:notch` est l'unique résident persistant de la maison Indev dans `sanctuary:alpha` ; il n'apparaît jamais naturellement et ne peut pas être invoqué par les commandes génériques. +- Son UUID canonique est sauvegardé, les doublons sont supprimés et le service le recrée au domicile s'il manque. L'alias caché `portals:notch` charge les anciennes entités du prototype sans dupliquer les règles. +- Le premier coup volontaire d'un joueur transforme l'humain en Titan. Le nombre de participants est borné à huit, la santé va de 360 à 600 et un combat abandonné se réinitialise après quinze secondes sans participant actif. +- It's Alive possède l'entité et son cycle de vie ; les attaques passent exclusivement par l'API publique `NotchCombatApi`, implémentée par Ouch. +- Diagnostics : `/masterkey notch inspect` et `/masterkey notch restore`. ## Creepers diff --git a/itsalive/build.gradle b/itsalive/build.gradle index 89e3849..d5b38b3 100644 --- a/itsalive/build.gradle +++ b/itsalive/build.gradle @@ -35,6 +35,14 @@ tasks.register("culinaryPartialMatchingSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.itsalive.culinary.recipe.CulinaryPartialMatchingSmoke" } +tasks.register("notchFightRulesSmoke", JavaExec) { + group = "verification" + description = "Checks bounded Notch Titan scaling, reset timing and reflected-fireball damage." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchFightRulesSmoke" +} + tasks.named("test") { // Quantity contracts are executable smoke tasks, not JUnit engine tests. failOnNoDiscoveredTests = false @@ -43,6 +51,7 @@ tasks.named("test") { tasks.named("check") { dependsOn tasks.named("canapliaFoundationSmoke") dependsOn tasks.named("canapliaGameplaySmoke") + dependsOn tasks.named("notchFightRulesSmoke") } def generatedResources = layout.buildDirectory.dir("generated/resources") @@ -556,7 +565,7 @@ tasks.register("verifyItsAlive") { def climateSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ClimateCropForaging.java").text def pineappleSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/PineappleItem.java").text def worldgenSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ItsAliveAgricultureWorldGeneration.java").text - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || climateMigration.target?.pack_version != "26.2.0-alpha.157" || climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24" || !climateSource.contains("PlayerBlockBreakEvents.AFTER.register") @@ -1313,7 +1322,7 @@ tasks.register("verifyCulinaryReliability") { } } } - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || migration.target?.modules?.itsalive != "0.0.0-alpha.28" || migration.culinary_fixes?.food_press_contents_destroyed_on_break != true || migration.culinary_fixes?.comparator_contract?.blocks?.size() != 6 @@ -1351,7 +1360,7 @@ tasks.register("verifyCauldronBatching") { def recipe = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryProcessRecipe.java").text def cauldron = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/CookingCauldronBlockEntity.java").text def timed = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/AbstractTimedCulinaryBlockEntity.java").text - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || migration.target?.modules?.itsalive != "0.0.0-alpha.29" || migration.cauldron_batches?.logical_stack_limit != 64 || migration.cauldron_batches?.identity != "same_item_same_components" @@ -1389,7 +1398,7 @@ tasks.register("verifyFermentationBarrelAutomation") { def partialMatching = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryPartialMatching.java").text def blockstate = new JsonSlurper().parse( file("src/main/resources/assets/itsalive/blockstates/fermentation_barrel.json")) - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || migration.source?.modules?.itsalive != "0.0.0-alpha.29" || migration.target?.modules?.itsalive != "0.0.0-alpha.30" || migration.fermentation_barrel?.maximum_units_per_input_slot != 1 @@ -1437,7 +1446,7 @@ tasks.register("verifyCulinaryLecternRelease") { [(locale): new JsonSlurper().parse(file( "src/main/resources/assets/itsalive/lang/${locale}.json"))] } - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || migration.target?.modules?.itsalive != "0.0.0-alpha.31" || migration.culinary_lectern?.book_tag != "minecraft:lectern_books" || migration.culinary_lectern?.owner_catalog_shared_while_placed != true @@ -1500,7 +1509,7 @@ tasks.register("verifyCanapliaForbiddenBook") { fr_fr: "Livre interdit", en_us: "Forbidden Book", ru_ru: "Запретная книга"] - if (project.version.toString() != "0.0.0-alpha.36" + if (project.version.toString() != "0.0.0-alpha.37" || !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)") || !knowledge.contains("unknownVariants(data.personal(player.getUUID()))") || !knowledge.contains("data.personal(inscription.ownerId())") diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/ItsAliveMod.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/ItsAliveMod.java index 6b2c5a8..e3e1db9 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/ItsAliveMod.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/ItsAliveMod.java @@ -1,6 +1,7 @@ package fr.koka99cab.sanctuary26.itsalive; import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundation; +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchResidentService; import fr.koka99cab.sanctuary26.itsalive.network.ItsAliveNetworking; import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryRecipeKnowledge; import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveRegistries; @@ -13,7 +14,7 @@ import org.slf4j.LoggerFactory; public final class ItsAliveMod implements ModInitializer { public static final String MOD_ID = "itsalive"; public static final String DISPLAY_NAME = "It's Alive !"; - public static final int CURRENT_DATA_VERSION = 3; + public static final int CURRENT_DATA_VERSION = 4; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public ItsAliveMod() { @@ -27,6 +28,7 @@ public final class ItsAliveMod implements ModInitializer { public void onInitialize() { CanapliaFoundation.initialize(); ItsAliveRegistries.initialize(); + NotchResidentService.initialize(); ItsAliveNetworking.initialize(); CulinaryRecipeKnowledge.initialize(); ProceduralVillagerNames.initialize(); diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/api/notch/NotchCombatApi.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/api/notch/NotchCombatApi.java new file mode 100644 index 0000000..2aaa198 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/api/notch/NotchCombatApi.java @@ -0,0 +1,46 @@ +package fr.koka99cab.sanctuary26.itsalive.api.notch; + +import java.util.Objects; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.LivingEntity; + +/** Public bridge allowing the combat-owning Ouch module to drive Notch attacks. */ +public final class NotchCombatApi { + private static Engine engine = Engine.NOOP; + private static boolean registered; + + private NotchCombatApi() { + } + + public static synchronized void register(Engine implementation) { + Objects.requireNonNull(implementation, "implementation"); + if (registered) throw new IllegalStateException("A Notch combat engine is already registered"); + engine = implementation; + registered = true; + } + + public static void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { + engine.telegraph(level, boss, target, attack); + } + + public static void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { + engine.execute(level, boss, target, attack); + } + + public enum Attack { + MELEE, + FIREBALL, + LIGHTNING + } + + public interface Engine { + Engine NOOP = new Engine() { + @Override public void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { } + @Override public void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { } + }; + + void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack); + + void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack); + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/ItsAliveClient.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/ItsAliveClient.java index 4498ad2..6d545b3 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/ItsAliveClient.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/ItsAliveClient.java @@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.itsalive.client.model.ButterflyModel; import fr.koka99cab.sanctuary26.itsalive.client.model.GhostModel; import fr.koka99cab.sanctuary26.itsalive.client.model.FlyingWhaleModel; import fr.koka99cab.sanctuary26.itsalive.client.model.StandingCowModel; +import fr.koka99cab.sanctuary26.itsalive.client.model.NotchModel; import fr.koka99cab.sanctuary26.itsalive.client.renderer.BrackenRenderer; import fr.koka99cab.sanctuary26.itsalive.client.renderer.ButterflyRenderer; import fr.koka99cab.sanctuary26.itsalive.client.renderer.GhostRenderer; @@ -12,6 +13,7 @@ import fr.koka99cab.sanctuary26.itsalive.client.renderer.GemChickenRenderer; import fr.koka99cab.sanctuary26.itsalive.client.renderer.FlyingWhaleRenderer; import fr.koka99cab.sanctuary26.itsalive.client.renderer.InfectedRenderer; import fr.koka99cab.sanctuary26.itsalive.client.renderer.MoobloomRenderer; +import fr.koka99cab.sanctuary26.itsalive.client.renderer.NotchRenderer; import fr.koka99cab.sanctuary26.itsalive.culinary.client.renderer.FryingPanBlockEntityRenderer; import fr.koka99cab.sanctuary26.itsalive.culinary.client.renderer.FermentationBarrelBlockEntityRenderer; import fr.koka99cab.sanctuary26.itsalive.culinary.client.FermentationBarrelHud; @@ -62,6 +64,7 @@ public final class ItsAliveClient implements ClientModInitializer { ModelLayerRegistry.registerModelLayer(StandingCowModel.TEMPERATE_LAYER, StandingCowModel::createTemperateLayer); ModelLayerRegistry.registerModelLayer(StandingCowModel.COLD_LAYER, StandingCowModel::createColdLayer); ModelLayerRegistry.registerModelLayer(StandingCowModel.WARM_LAYER, StandingCowModel::createWarmLayer); + ModelLayerRegistry.registerModelLayer(NotchModel.LAYER_LOCATION, NotchModel::createBodyLayer); EntityRendererRegistry.register(ItsAliveEntityTypes.BUTTERFLY, ButterflyRenderer::new); EntityRendererRegistry.register(ItsAliveEntityTypes.GHOST, GhostRenderer::new); EntityRendererRegistry.register(ItsAliveEntityTypes.BRACKEN, BrackenRenderer::new); @@ -70,6 +73,8 @@ public final class ItsAliveClient implements ClientModInitializer { EntityRendererRegistry.register(ItsAliveEntityTypes.MOOBLOOM, MoobloomRenderer::new); EntityRendererRegistry.register(ItsAliveEntityTypes.GEM_CHICKEN, GemChickenRenderer::new); EntityRendererRegistry.register(ItsAliveEntityTypes.ACID_GLOB, ThrownItemRenderer::new); + EntityRendererRegistry.register(ItsAliveEntityTypes.NOTCH, NotchRenderer::new); + EntityRendererRegistry.register(ItsAliveEntityTypes.LEGACY_NOTCH, NotchRenderer::new); BlockEntityRendererRegistry.register( ItsAliveFoodProcessing.FRYING_PAN_BLOCK_ENTITY, FryingPanBlockEntityRenderer::new diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/model/NotchModel.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/model/NotchModel.java new file mode 100644 index 0000000..fef9c62 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/model/NotchModel.java @@ -0,0 +1,21 @@ +package fr.koka99cab.sanctuary26.itsalive.client.model; + +import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; +import fr.koka99cab.sanctuary26.itsalive.client.renderer.state.NotchRenderState; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.geom.ModelLayerLocation; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.geom.builders.CubeDeformation; +import net.minecraft.client.model.geom.builders.LayerDefinition; + +public final class NotchModel extends HumanoidModel { + public static final ModelLayerLocation LAYER_LOCATION = new ModelLayerLocation(ItsAliveMod.id("notch"), "main"); + + public NotchModel(ModelPart root) { + super(root); + } + + public static LayerDefinition createBodyLayer() { + return LayerDefinition.create(HumanoidModel.createMesh(CubeDeformation.NONE, 0.0F), 64, 64); + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/NotchRenderer.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/NotchRenderer.java new file mode 100644 index 0000000..69186aa --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/NotchRenderer.java @@ -0,0 +1,45 @@ +package fr.koka99cab.sanctuary26.itsalive.client.renderer; + +import com.mojang.blaze3d.vertex.PoseStack; +import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; +import fr.koka99cab.sanctuary26.itsalive.client.model.NotchModel; +import fr.koka99cab.sanctuary26.itsalive.client.renderer.state.NotchRenderState; +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.HumanoidMobRenderer; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; + +public final class NotchRenderer extends HumanoidMobRenderer { + private static final Identifier TEXTURE = ItsAliveMod.id("textures/entity/notch.png"); + + public NotchRenderer(EntityRendererProvider.Context context) { + super(context, new NotchModel(context.bakeLayer(NotchModel.LAYER_LOCATION)), 0.5F); + } + + @Override + public Identifier getTextureLocation(NotchRenderState state) { + return TEXTURE; + } + + @Override + public NotchRenderState createRenderState() { + return new NotchRenderState(); + } + + @Override + public void extractRenderState(NotchEntity notch, NotchRenderState state, float partialTick) { + super.extractRenderState(notch, state, partialTick); + state.titan = notch.isTitan(); + state.titanTransformationProgress = notch.titanTransformationProgress(partialTick); + } + + @Override + protected void scale(NotchRenderState state, PoseStack poseStack) { + super.scale(state, poseStack); + float scale = state.titan + ? Mth.lerp(state.titanTransformationProgress, 1.0F, NotchEntity.TITAN_RENDER_SCALE) + : 1.0F; + poseStack.scale(scale, scale, scale); + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/state/NotchRenderState.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/state/NotchRenderState.java new file mode 100644 index 0000000..5e9b8c4 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/client/renderer/state/NotchRenderState.java @@ -0,0 +1,8 @@ +package fr.koka99cab.sanctuary26.itsalive.client.renderer.state; + +import net.minecraft.client.renderer.entity.state.HumanoidRenderState; + +public final class NotchRenderState extends HumanoidRenderState { + public boolean titan; + public float titanTransformationProgress; +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchEntity.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchEntity.java new file mode 100644 index 0000000..8f32100 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchEntity.java @@ -0,0 +1,495 @@ +package fr.koka99cab.sanctuary26.itsalive.entity.notch; + +import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; +import fr.koka99cab.sanctuary26.itsalive.api.notch.NotchCombatApi; +import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone; +import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.chat.Component; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerBossEvent; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.Mth; +import net.minecraft.world.BossEvent; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeSupplier; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; +import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal; +import net.minecraft.world.entity.ai.goal.WaterAvoidingRandomStrollGoal; +import net.minecraft.world.entity.ai.goal.target.NearestAttackableTargetGoal; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; + +/** The unique human resident of Alpha and his server-authoritative Titan phase. */ +public final class NotchEntity extends PathfinderMob { + private static final EntityDataAccessor PHASE = SynchedEntityData.defineId( + NotchEntity.class, EntityDataSerializers.BYTE); + private static final EntityDataAccessor PHASE_STARTED_TICK = SynchedEntityData.defineId( + NotchEntity.class, EntityDataSerializers.INT); + public static final float TITAN_RENDER_SCALE = 8.0F; + private static final float TITAN_HITBOX_SCALE = 5.0F; + private static final double HUMAN_HOME_RADIUS = 9.0D; + private static final double TITAN_HOME_RADIUS = 54.0D; + private static final double PARTICIPANT_RADIUS = 64.0D; + private static final String DATA_VERSION_KEY = "ItsAliveDataVersion"; + private static final String PARTICIPANTS_KEY = "NotchParticipants"; + private static final String LEGACY_REWARD_KEY = "NotchRewardGranted"; + private final ServerBossEvent bossEvent = new ServerBossEvent(UUID.randomUUID(), + Component.translatable("boss.itsalive.notch_titan"), BossEvent.BossBarColor.RED, + BossEvent.BossBarOverlay.NOTCHED_20); + private final LinkedHashSet participants = new LinkedHashSet<>(); + private BlockPos home; + private int ticksWithoutParticipant; + private float titanMaximumHealth = NotchFightRules.MIN_TITAN_HEALTH; + private boolean legacyRewardGranted; + + public NotchEntity(net.minecraft.world.entity.EntityType type, Level level) { + super(type, level); + this.xpReward = 0; + this.setCustomName(Component.translatable("entity.itsalive.notch")); + this.setCustomNameVisible(true); + this.setPersistenceRequired(); + this.bossEvent.setDarkenScreen(true); + this.bossEvent.setCreateWorldFog(true); + this.bossEvent.setVisible(false); + this.applyPhaseAttributes(true); + } + + public static AttributeSupplier.Builder createAttributes() { + return Mob.createMobAttributes() + .add(Attributes.MAX_HEALTH, NotchFightRules.HUMAN_HEALTH) + .add(Attributes.ATTACK_DAMAGE, 1.0D) + .add(Attributes.FOLLOW_RANGE, 96.0D) + .add(Attributes.MOVEMENT_SPEED, 0.16D) + .add(Attributes.KNOCKBACK_RESISTANCE, 1.0D) + .add(Attributes.ARMOR, 0.0D) + .add(Attributes.ARMOR_TOUGHNESS, 0.0D) + .add(Attributes.STEP_HEIGHT, 2.0D); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + super.defineSynchedData(builder); + builder.define(PHASE, (byte)NotchFightRules.Phase.HUMAN.id()); + builder.define(PHASE_STARTED_TICK, 0); + } + + @Override + protected void registerGoals() { + this.goalSelector.addGoal(0, new FloatGoal(this)); + this.goalSelector.addGoal(1, new TitanCombatGoal()); + this.goalSelector.addGoal(5, new BoundedStrollGoal()); + this.goalSelector.addGoal(6, new LookAtPlayerGoal(this, Player.class, 24.0F)); + this.goalSelector.addGoal(7, new RandomLookAroundGoal(this)); + this.targetSelector.addGoal(1, new NearestAttackableTargetGoal<>(this, Player.class, 10, + true, false, (target, level) -> this.isTitan() && target instanceof Player player + && this.canParticipate(player))); + } + + @Override + protected void customServerAiStep(ServerLevel level) { + super.customServerAiStep(level); + this.ensureHome(level); + this.bossEvent.setVisible(this.isTitan()); + if (this.isTitan()) { + this.collectParticipants(level); + this.bossEvent.setProgress(Mth.clamp(this.getHealth() / this.getMaxHealth(), 0.0F, 1.0F)); + if (this.hasActiveParticipant(level)) { + this.ticksWithoutParticipant = 0; + } else if (NotchFightRules.shouldReset(++this.ticksWithoutParticipant)) { + this.resetInterruptedFight(level); + return; + } + this.returnInsideBoundary(TITAN_HOME_RADIUS); + } else { + this.bossEvent.setProgress(1.0F); + this.ticksWithoutParticipant = 0; + if (this.getTarget() != null) this.setTarget(null); + this.returnInsideBoundary(HUMAN_HOME_RADIUS + 4.0D); + } + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float damage) { + if (this.isDefeated()) return false; + Entity attacker = source.getEntity(); + if (this.isHuman()) { + if (attacker instanceof ServerPlayer player) { + this.beginFight(level, player); + return true; + } + return false; + } + if (attacker == this) return false; + if (source.getDirectEntity() instanceof Projectile projectile && projectile.getOwner() == this) return false; + if (attacker instanceof ServerPlayer player) this.addParticipant(player); + boolean hurt = super.hurtServer(level, source, Math.max(0.0F, damage)); + if (hurt && attacker instanceof LivingEntity living && this.canAttack(living)) this.setTarget(living); + return hurt; + } + + @Override + public void die(DamageSource source) { + if (this.isTitan() && this.level() instanceof ServerLevel level) { + this.completeFight(level); + return; + } + super.die(source); + } + + @Override + public boolean canAttack(LivingEntity target) { + return this.isTitan() && super.canAttack(target); + } + + @Override + protected void checkFallDamage(double distance, boolean landed, BlockState state, BlockPos pos) { + } + + public void initializeAtHome(BlockPos home, boolean defeated) { + this.home = home.immutable(); + this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, 180.0F, 0.0F); + this.setYHeadRot(180.0F); + this.setPhase(defeated ? NotchFightRules.Phase.DEFEATED : NotchFightRules.Phase.HUMAN, true); + } + + public void restoreAtHome(BlockPos home, boolean defeated) { + this.participants.clear(); + this.ticksWithoutParticipant = 0; + this.setTarget(null); + this.getNavigation().stop(); + this.setDeltaMovement(Vec3.ZERO); + this.initializeAtHome(home, defeated); + } + + public NotchFightRules.Phase phase() { + return NotchFightRules.Phase.byId(this.entityData.get(PHASE)); + } + + public boolean isHuman() { + return phase() == NotchFightRules.Phase.HUMAN; + } + + public boolean isTitan() { + return phase() == NotchFightRules.Phase.TITAN; + } + + public boolean isDefeated() { + return phase() == NotchFightRules.Phase.DEFEATED; + } + + public float titanTransformationProgress(float partialTick) { + if (!isTitan()) return 0.0F; + return Mth.clamp((this.tickCount + partialTick - this.entityData.get(PHASE_STARTED_TICK)) / 30.0F, + 0.0F, 1.0F); + } + + public boolean legacyRewardGranted() { + return legacyRewardGranted; + } + + public Set participantIds() { + return Set.copyOf(participants); + } + + private void beginFight(ServerLevel level, ServerPlayer attacker) { + this.participants.clear(); + this.addParticipant(attacker); + level.players().stream() + .filter(this::canParticipate) + .sorted(java.util.Comparator.comparingDouble(this::distanceToSqr)) + .forEach(this::addParticipant); + this.titanMaximumHealth = NotchFightRules.titanHealth(participants.size()); + this.setPhase(NotchFightRules.Phase.TITAN, true); + this.setTarget(attacker); + this.bossEvent.setVisible(true); + this.spawnLightningRing(level, 4); + level.explode(this, this.getX(), this.getY() + 1.0D, this.getZ(), 4.0F, + Level.ExplosionInteraction.BLOCK); + this.playSound(SoundEvents.WITHER_SPAWN, 1.4F, 0.72F); + } + + private void completeFight(ServerLevel level) { + this.collectParticipants(level); + this.setPhase(NotchFightRules.Phase.DEFEATED, true); + this.setTarget(null); + this.getNavigation().stop(); + this.setDeltaMovement(Vec3.ZERO); + this.deathTime = 0; + this.bossEvent.setVisible(false); + this.spawnLightningRing(level, 3); + level.sendParticles(ParticleTypes.EXPLOSION_EMITTER, this.getX(), this.getY() + 1.0D, + this.getZ(), 1, 0.0D, 0.0D, 0.0D, 0.0D); + this.playSound(SoundEvents.WITHER_DEATH, 1.25F, 1.15F); + SanctuaryNotchMilestone.recordVictory(level, participants); + } + + private void resetInterruptedFight(ServerLevel level) { + this.setPhase(NotchFightRules.Phase.HUMAN, true); + this.participants.clear(); + this.setTarget(null); + this.getNavigation().stop(); + this.setDeltaMovement(Vec3.ZERO); + this.bossEvent.setVisible(false); + if (home != null) this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, 180.0F, 0.0F); + level.sendParticles(ParticleTypes.SMOKE, this.getX(), this.getY() + 1.0D, this.getZ(), + 18, 0.8D, 1.0D, 0.8D, 0.04D); + } + + private void setPhase(NotchFightRules.Phase phase, boolean heal) { + this.entityData.set(PHASE, (byte)phase.id()); + this.entityData.set(PHASE_STARTED_TICK, this.tickCount); + this.applyPhaseAttributes(heal); + this.refreshDimensions(); + } + + private void applyPhaseAttributes(boolean heal) { + boolean titan = isTitan(); + setBaseValue(Attributes.MAX_HEALTH, titan ? titanMaximumHealth : NotchFightRules.HUMAN_HEALTH); + setBaseValue(Attributes.ATTACK_DAMAGE, titan ? 18.0D : 1.0D); + setBaseValue(Attributes.MOVEMENT_SPEED, titan ? 0.235D : 0.16D); + setBaseValue(Attributes.ARMOR, titan ? 10.0D : 0.0D); + setBaseValue(Attributes.ARMOR_TOUGHNESS, titan ? 8.0D : 0.0D); + if (heal) this.setHealth(this.getMaxHealth()); + } + + private void setBaseValue(net.minecraft.core.Holder attribute, + double value) { + AttributeInstance instance = this.getAttribute(attribute); + if (instance != null) instance.setBaseValue(value); + } + + private void ensureHome(ServerLevel level) { + if (home == null) home = SanctuaryAlphaWorld.residence(level.getSeed()); + } + + private void returnInsideBoundary(double radius) { + if (home == null || this.distanceToSqr(Vec3.atCenterOf(home)) <= radius * radius) return; + this.getNavigation().stop(); + this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, this.getYRot(), this.getXRot()); + } + + private boolean canParticipate(Player player) { + return player.isAlive() && !player.isSpectator() && player.level() == this.level() + && this.distanceToSqr(player) <= PARTICIPANT_RADIUS * PARTICIPANT_RADIUS; + } + + private void addParticipant(Player player) { + if (participants.size() < NotchFightRules.MAX_PARTICIPANTS && canParticipate(player)) { + participants.add(player.getUUID()); + } + } + + private void collectParticipants(ServerLevel level) { + level.players().stream().filter(this::canParticipate) + .sorted(java.util.Comparator.comparingDouble(this::distanceToSqr)) + .forEach(this::addParticipant); + } + + private boolean hasActiveParticipant(ServerLevel level) { + return participants.stream().map(level.getServer().getPlayerList()::getPlayer) + .anyMatch(player -> player != null && canParticipate(player)); + } + + private void spawnLightningRing(ServerLevel level, int strikes) { + for (int index = 0; index < strikes; index++) { + double angle = Math.PI * 2.0D * index / Math.max(1, strikes); + net.minecraft.world.entity.LightningBolt lightning = net.minecraft.world.entity.EntityTypes.LIGHTNING_BOLT + .create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED); + if (lightning == null) continue; + lightning.setVisualOnly(true); + lightning.setPos(this.getX() + Math.cos(angle) * 2.4D, this.getY(), + this.getZ() + Math.sin(angle) * 2.4D); + level.addFreshEntity(lightning); + } + } + + @Override + protected EntityDimensions getDefaultDimensions(Pose pose) { + EntityDimensions dimensions = super.getDefaultDimensions(pose); + return isTitan() ? dimensions.scale(TITAN_HITBOX_SCALE) : dimensions; + } + + @Override + public void onSyncedDataUpdated(EntityDataAccessor key) { + super.onSyncedDataUpdated(key); + if (PHASE.equals(key)) this.refreshDimensions(); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putInt(DATA_VERSION_KEY, ItsAliveMod.CURRENT_DATA_VERSION); + output.putByte("NotchPhase", this.entityData.get(PHASE)); + output.putInt("NotchPhaseStartedTick", this.entityData.get(PHASE_STARTED_TICK)); + output.putFloat("NotchStoredHealth", this.getHealth()); + output.putFloat("NotchTitanMaximumHealth", this.titanMaximumHealth); + output.putString(PARTICIPANTS_KEY, participants.stream().map(UUID::toString).collect(Collectors.joining(","))); + output.putBoolean(LEGACY_REWARD_KEY, legacyRewardGranted); + if (home != null) { + output.putInt("NotchHomeX", home.getX()); + output.putInt("NotchHomeY", home.getY()); + output.putInt("NotchHomeZ", home.getZ()); + } + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + NotchFightRules.Phase phase = NotchFightRules.Phase.byId(input.getByteOr("NotchPhase", (byte)0)); + this.entityData.set(PHASE, (byte)phase.id()); + this.entityData.set(PHASE_STARTED_TICK, input.getIntOr("NotchPhaseStartedTick", 0)); + this.titanMaximumHealth = Mth.clamp(input.getFloatOr("NotchTitanMaximumHealth", + NotchFightRules.MIN_TITAN_HEALTH), NotchFightRules.MIN_TITAN_HEALTH, + NotchFightRules.MAX_TITAN_HEALTH); + this.participants.clear(); + input.getString(PARTICIPANTS_KEY).ifPresent(value -> { + for (String token : value.split(",")) { + if (participants.size() >= NotchFightRules.MAX_PARTICIPANTS) break; + try { participants.add(UUID.fromString(token)); } catch (IllegalArgumentException ignored) { } + } + }); + this.legacyRewardGranted = input.getBooleanOr(LEGACY_REWARD_KEY, false); + if (input.getInt("NotchHomeX").isPresent() && input.getInt("NotchHomeY").isPresent() + && input.getInt("NotchHomeZ").isPresent()) { + this.home = new BlockPos(input.getIntOr("NotchHomeX", 0), input.getIntOr("NotchHomeY", 0), + input.getIntOr("NotchHomeZ", 0)); + } + this.applyPhaseAttributes(false); + this.setHealth(Mth.clamp(input.getFloatOr("NotchStoredHealth", this.getMaxHealth()), + 1.0F, this.getMaxHealth())); + this.refreshDimensions(); + this.bossEvent.setVisible(isTitan()); + } + + @Override + public void startSeenByPlayer(ServerPlayer player) { + super.startSeenByPlayer(player); + this.bossEvent.addPlayer(player); + } + + @Override + public void stopSeenByPlayer(ServerPlayer player) { + super.stopSeenByPlayer(player); + this.bossEvent.removePlayer(player); + } + + @Override + public boolean removeWhenFarAway(double distance) { + return false; + } + + @Override + public int getMaxSpawnClusterSize() { + return 1; + } + + private final class BoundedStrollGoal extends WaterAvoidingRandomStrollGoal { + private BoundedStrollGoal() { + super(NotchEntity.this, 0.72D, 0.008F); + } + + @Override public boolean canUse() { return !NotchEntity.this.isTitan() && super.canUse(); } + @Override public boolean canContinueToUse() { return !NotchEntity.this.isTitan() && super.canContinueToUse(); } + } + + private final class TitanCombatGoal extends Goal { + private static final double STRAFE_DISTANCE_SQUARED = 18.0D * 18.0D; + private int rangedCooldown; + private int meleeCooldown; + private int attackIndex; + private int windup; + private NotchCombatApi.Attack pendingAttack; + private boolean clockwise; + private int strafeTicks; + + private TitanCombatGoal() { + this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK)); + } + + @Override public boolean canUse() { + LivingEntity target = NotchEntity.this.getTarget(); + return NotchEntity.this.isTitan() && target != null && target.isAlive(); + } + + @Override public boolean canContinueToUse() { return canUse(); } + + @Override public void start() { + rangedCooldown = 45; + meleeCooldown = 0; + attackIndex = 0; + windup = 0; + pendingAttack = null; + clockwise = NotchEntity.this.getRandom().nextBoolean(); + } + + @Override public void stop() { + NotchEntity.this.getNavigation().stop(); + pendingAttack = null; + } + + @Override public boolean requiresUpdateEveryTick() { return true; } + + @Override public void tick() { + LivingEntity target = NotchEntity.this.getTarget(); + if (target == null || !(NotchEntity.this.level() instanceof ServerLevel level)) return; + double distanceSquared = NotchEntity.this.distanceToSqr(target); + boolean canSee = NotchEntity.this.getSensing().hasLineOfSight(target); + NotchEntity.this.getLookControl().setLookAt(target, 30.0F, 30.0F); + if (!canSee || distanceSquared > STRAFE_DISTANCE_SQUARED) { + NotchEntity.this.getNavigation().moveTo(target, 1.12D); + } else { + NotchEntity.this.getNavigation().stop(); + if (++strafeTicks >= 45) { strafeTicks = 0; clockwise = !clockwise; } + NotchEntity.this.getMoveControl().strafe(distanceSquared < 100.0D ? -0.45F : 0.28F, + clockwise ? 0.7F : -0.7F); + } + if (meleeCooldown > 0) meleeCooldown--; + if (distanceSquared <= 64.0D && meleeCooldown == 0) { + NotchCombatApi.execute(level, NotchEntity.this, target, NotchCombatApi.Attack.MELEE); + meleeCooldown = 24; + } + if (pendingAttack != null) { + if (--windup <= 0) { + NotchCombatApi.execute(level, NotchEntity.this, target, pendingAttack); + pendingAttack = null; + rangedCooldown = 65 + NotchEntity.this.getRandom().nextInt(32); + } + return; + } + if (rangedCooldown > 0) rangedCooldown--; + if (canSee && rangedCooldown == 0) { + pendingAttack = attackIndex++ % 5 == 4 + ? NotchCombatApi.Attack.LIGHTNING : NotchCombatApi.Attack.FIREBALL; + windup = pendingAttack == NotchCombatApi.Attack.LIGHTNING ? 24 : 14; + NotchCombatApi.telegraph(level, NotchEntity.this, target, pendingAttack); + } + } + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRules.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRules.java new file mode 100644 index 0000000..bb8e60b --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRules.java @@ -0,0 +1,49 @@ +package fr.koka99cab.sanctuary26.itsalive.entity.notch; + +import net.minecraft.util.Mth; + +/** Pure bounded rules shared by the live Notch entity and deterministic tests. */ +public final class NotchFightRules { + public static final int MAX_PARTICIPANTS = 8; + public static final int RESET_AFTER_EMPTY_TICKS = 20 * 15; + public static final float HUMAN_HEALTH = 20.0F; + public static final float MIN_TITAN_HEALTH = 360.0F; + public static final float MAX_TITAN_HEALTH = 600.0F; + public static final float RETURNED_FIREBALL_DAMAGE = 40.0F; + + private NotchFightRules() { + } + + public static float titanHealth(int participantCount) { + int bounded = Mth.clamp(participantCount, 1, MAX_PARTICIPANTS); + return Math.min(MAX_TITAN_HEALTH, MIN_TITAN_HEALTH + (bounded - 1) * 40.0F); + } + + public static boolean shouldReset(int ticksWithoutParticipant) { + return ticksWithoutParticipant >= RESET_AFTER_EMPTY_TICKS; + } + + public enum Phase { + HUMAN(0), + TITAN(1), + DEFEATED(2); + + private final int id; + + Phase(int id) { + this.id = id; + } + + public int id() { + return id; + } + + public static Phase byId(int id) { + return switch (id) { + case 1 -> TITAN; + case 2 -> DEFEATED; + default -> HUMAN; + }; + } + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchResidentService.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchResidentService.java new file mode 100644 index 0000000..8e1a492 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchResidentService.java @@ -0,0 +1,156 @@ +package fr.koka99cab.sanctuary26.itsalive.entity.notch; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; +import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveEntityTypes; +import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone; +import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld; +import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Maintains exactly one persistent Notch resident in the finite Alpha world. */ +public final class NotchResidentService { + private static final int CHECK_INTERVAL = 40; + private static boolean initialized; + + private NotchResidentService() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + ServerTickEvents.END_LEVEL_TICK.register(NotchResidentService::tick); + ItsAliveMod.LOGGER.debug("[{}] Registered the unique Alpha resident service.", + ItsAliveMod.DISPLAY_NAME); + } + + public static Inspection inspect(MinecraftServer server) { + ServerLevel level = server.getLevel(SanctuaryDimensions.ALPHA); + if (level == null) return Inspection.unavailable(); + SpawnState state = level.getDataStorage().computeIfAbsent(SpawnState.TYPE); + List residents = loadedResidents(level); + NotchEntity canonical = selectCanonical(residents, state.canonicalUuid()).orElse(null); + return new Inspection(true, residents.size(), canonical == null ? null : canonical.getUUID(), + canonical == null ? null : canonical.phase(), canonical == null ? null : canonical.blockPosition(), + SanctuaryNotchMilestone.isDefeated(server)); + } + + /** Force-loads the house chunk, removes duplicates and restores the canonical resident. */ + public static Inspection restore(MinecraftServer server) { + ServerLevel level = server.getLevel(SanctuaryDimensions.ALPHA); + if (level == null) return Inspection.unavailable(); + BlockPos home = SanctuaryAlphaWorld.residence(level.getSeed()); + level.getChunkAt(home); + reconcile(level, true); + return inspect(server); + } + + private static void tick(ServerLevel level) { + if (!level.dimension().equals(SanctuaryDimensions.ALPHA) + || level.players().isEmpty() + || level.getGameTime() % CHECK_INTERVAL != 0) return; + reconcile(level, false); + } + + private static void reconcile(ServerLevel level, boolean forceHome) { + BlockPos home = SanctuaryAlphaWorld.residence(level.getSeed()); + if (!forceHome && !level.isLoaded(home)) return; + SpawnState state = level.getDataStorage().computeIfAbsent(SpawnState.TYPE); + List residents = loadedResidents(level); + NotchEntity canonical = selectCanonical(residents, state.canonicalUuid()).orElse(null); + if (canonical == null) { + canonical = ItsAliveEntityTypes.NOTCH.create(level, EntitySpawnReason.EVENT); + if (canonical == null) return; + canonical.initializeAtHome(home, SanctuaryNotchMilestone.isDefeated(level.getServer())); + if (!level.addFreshEntity(canonical)) return; + residents = List.of(canonical); + ItsAliveMod.LOGGER.info("[{}] Placed Notch in the Alpha residence at {}.", + ItsAliveMod.DISPLAY_NAME, home); + } + for (NotchEntity resident : residents) { + if (resident != canonical) resident.discard(); + } + state.setCanonicalUuid(canonical.getUUID()); + if (canonical.isDefeated() && !SanctuaryNotchMilestone.isDefeated(level.getServer())) { + SanctuaryNotchMilestone.migrateLegacyVictory(level, canonical.legacyRewardGranted()); + } + boolean defeated = SanctuaryNotchMilestone.isDefeated(level.getServer()); + if (forceHome || defeated != canonical.isDefeated()) canonical.restoreAtHome(home, defeated); + } + + private static List loadedResidents(ServerLevel level) { + List residents = new ArrayList<>(); + for (Entity entity : level.getAllEntities()) { + if (entity instanceof NotchEntity notch && !notch.isRemoved()) residents.add(notch); + } + return residents; + } + + private static Optional selectCanonical(List residents, UUID storedUuid) { + return residents.stream().min(Comparator + .comparing((NotchEntity notch) -> !notch.getUUID().equals(storedUuid)) + .thenComparing(notch -> notch.getType() != ItsAliveEntityTypes.NOTCH) + .thenComparing(notch -> notch.getUUID().toString())); + } + + public record Inspection(boolean alphaAvailable, int loadedResidents, UUID canonicalUuid, + NotchFightRules.Phase phase, BlockPos position, boolean worldDefeated) { + private static Inspection unavailable() { + return new Inspection(false, 0, null, null, null, false); + } + } + + private static final class SpawnState extends SavedData { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.optionalFieldOf("data_version", ItsAliveMod.CURRENT_DATA_VERSION) + .forGetter(state -> state.dataVersion), + Codec.STRING.optionalFieldOf("canonical_uuid", "").forGetter(state -> state.canonicalUuid) + ).apply(instance, SpawnState::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + ItsAliveMod.id("alpha_notch_resident"), SpawnState::new, CODEC, DataFixTypes.LEVEL); + private int dataVersion; + private String canonicalUuid; + + private SpawnState() { + this(ItsAliveMod.CURRENT_DATA_VERSION, ""); + } + + private SpawnState(int dataVersion, String canonicalUuid) { + this.dataVersion = Math.max(1, dataVersion); + this.canonicalUuid = canonicalUuid == null ? "" : canonicalUuid; + if (this.dataVersion < ItsAliveMod.CURRENT_DATA_VERSION) { + this.dataVersion = ItsAliveMod.CURRENT_DATA_VERSION; + setDirty(); + } + } + + private UUID canonicalUuid() { + try { + return UUID.fromString(canonicalUuid); + } catch (IllegalArgumentException ignored) { + return new UUID(0L, 0L); + } + } + + private void setCanonicalUuid(UUID uuid) { + String value = uuid.toString(); + if (value.equals(canonicalUuid)) return; + canonicalUuid = value; + setDirty(); + } + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveEntityTypes.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveEntityTypes.java index 38599c4..6de79fd 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveEntityTypes.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveEntityTypes.java @@ -9,6 +9,7 @@ import fr.koka99cab.sanctuary26.itsalive.entity.FlyingWhaleEntity; import fr.koka99cab.sanctuary26.itsalive.entity.GemChickenEntity; import fr.koka99cab.sanctuary26.itsalive.entity.InfectedEntity; import fr.koka99cab.sanctuary26.itsalive.entity.MoobloomEntity; +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity; import java.util.function.Predicate; import net.fabricmc.fabric.api.biome.v1.BiomeModifications; import net.fabricmc.fabric.api.biome.v1.BiomeSelectionContext; @@ -44,6 +45,8 @@ public final class ItsAliveEntityTypes { public static final Identifier ACID_GLOB_ID = ItsAliveMod.id("acid_glob"); public static final Identifier MOOBLOOM_ID = ItsAliveMod.id("moobloom"); public static final Identifier GEM_CHICKEN_ID = ItsAliveMod.id("gem_chicken"); + public static final Identifier NOTCH_ID = ItsAliveMod.id("notch"); + public static final Identifier LEGACY_NOTCH_ID = Identifier.fromNamespaceAndPath("portals", "notch"); public static final EntityType BUTTERFLY = Registry.register( BuiltInRegistries.ENTITY_TYPE, @@ -167,6 +170,10 @@ public final class ItsAliveEntityTypes { .build(ResourceKey.create(Registries.ENTITY_TYPE, GEM_CHICKEN_ID)) ); + public static final EntityType NOTCH = registerNotch(NOTCH_ID); + /** Hidden alias used only to deserialize Notch entities written by the historical Portals prototype. */ + public static final EntityType LEGACY_NOTCH = registerNotch(LEGACY_NOTCH_ID); + private ItsAliveEntityTypes() { } @@ -192,7 +199,19 @@ public final class ItsAliveEntityTypes { entity.discard(); } }); - ItsAliveMod.LOGGER.info("[{}] Registered papillon, 16-variant moobloom, three gem chickens, ghost, bracken, flying whale and five-role infected; vanilla phantoms disabled.", + ItsAliveMod.LOGGER.info("[{}] Registered papillon, 16-variant moobloom, three gem chickens, ghost, bracken, flying whale, five-role infected and the unique Alpha resident; vanilla phantoms disabled.", ItsAliveMod.DISPLAY_NAME); } + + private static EntityType registerNotch(Identifier id) { + return Registry.register(BuiltInRegistries.ENTITY_TYPE, id, + FabricEntityType.Builder.createMob(NotchEntity::new, MobCategory.CREATURE, + mob -> mob.defaultAttributes(NotchEntity::createAttributes)) + .sized(0.6F, 1.8F) + .clientTrackingRange(96) + .updateInterval(2) + .noSummon() + .noLootTable() + .build(ResourceKey.create(Registries.ENTITY_TYPE, id))); + } } diff --git a/itsalive/src/main/resources/assets/itsalive/lang/en_us.json b/itsalive/src/main/resources/assets/itsalive/lang/en_us.json index 99a275d..91c5a67 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/en_us.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/en_us.json @@ -288,5 +288,7 @@ "hud.itsalive.cooking_cauldron.assembly": "Mixture incomplete: %s", "hud.itsalive.cooking_cauldron.cooking": "Cooking: %s", "hud.itsalive.cooking_cauldron.expired": "Mixture failed — collect the failed dish", - "hud.itsalive.cooking_cauldron.ready": "Dish ready" + "hud.itsalive.cooking_cauldron.ready": "Dish ready", + "entity.itsalive.notch": "Notch", + "boss.itsalive.notch_titan": "Notch Titan" } diff --git a/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json b/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json index fe881c1..dec37a7 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json @@ -288,5 +288,7 @@ "hud.itsalive.cooking_cauldron.assembly": "Mélange incomplet : %s", "hud.itsalive.cooking_cauldron.cooking": "Cuisson : %s", "hud.itsalive.cooking_cauldron.expired": "Plat raté — récupère le résultat", - "hud.itsalive.cooking_cauldron.ready": "Plat prêt" + "hud.itsalive.cooking_cauldron.ready": "Plat prêt", + "entity.itsalive.notch": "Notch", + "boss.itsalive.notch_titan": "Notch Titan" } diff --git a/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json b/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json index 654f427..d3735d5 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json @@ -288,5 +288,7 @@ "hud.itsalive.cooking_cauldron.assembly": "Mixture incomplete: %s", "hud.itsalive.cooking_cauldron.cooking": "Cooking: %s", "hud.itsalive.cooking_cauldron.expired": "Mixture failed — collect the failed dish", - "hud.itsalive.cooking_cauldron.ready": "Dish ready" + "hud.itsalive.cooking_cauldron.ready": "Dish ready", + "entity.itsalive.notch": "Нотч", + "boss.itsalive.notch_titan": "Нотч-Титан" } diff --git a/itsalive/src/main/resources/assets/itsalive/textures/entity/notch.png b/itsalive/src/main/resources/assets/itsalive/textures/entity/notch.png new file mode 100644 index 0000000000000000000000000000000000000000..2e735d093cb34d4697f0421851cc71ebc52b196a GIT binary patch literal 1128 zcmV-u1eg1XP)rSXht< zm2@T)1~k#c#CS1L(c;7d6==?UYu@JVxHr4UuD8qGyk_@y=4Rjf-uK?j&Krw-q&PcM z)^)jcZ-Va3FMD>mdbSH~mft(#Z1+{p01$uyKQ}F!T3B*9^5U7(w7i!s|4y5Wk2{Hg) zRvG~6fdNG1;&v{Vb2&So&sQ}KV-G+l_;AvR`caorkdA zz%{GnDk9Rp5RH)oP{~Lvjl%-a-__y5Wcuc4;{pH%+!!8mybl&&eWFP18KB!D3(EjC z1VRIl%jN74C>D#9PN%86yW0ie;^Lyyh6dSQC=_V-+viSyX`I&%(YNnwbo_9OYkWUI z5H?}N?_Edcn>r)M+tb@gvjI@H$W+#^vvS$=OnN8C!oxj))k_BLzns~G?= zUiC13>llFcmyaJf1^@sS7zq$E2U}_V_ofXFL>eCy0I)d@=7y<4WEMVzBH3fE@z>Pjk{BNq;uB?f^j8 z@G275-rnASh%`4hM<>f)Y;OaA*VYlc4-#+N=Qmp7C7uBi09c~t5Vj5=v~R4?>d_Of zrF9Yj*iPuz&3|GoN+bbA13(hI5T_1?0dbR=cUC;CZvw_!jMybz}j zh5>P+pM-ZC1|-1?aq18`1Gv`6MtN+gYTr4~(^;1Fe~;p4q&i1B7>o`JKz~<aX9} zH3NXR!PQ;kh5!J@S2}@$@_IG(evBp01NfD{6Ttu-0OvBhoZ3Im06@6*k1NXGd^W(V z-LL#z69Cw|c$rsmRRW-v*DrUsyL61Y{$DcyWQ?ol3+f&K9mR3Jz%K-(mv$Tj!k#ZE u=0.154.2+26.2", "anotherworld": "*", "ambiance": ">=0.0.0-alpha.14", - "sanctuary": ">=0.0.0-alpha.117" + "sanctuary": ">=0.0.0-alpha.126" }, "custom": { "sanctuary26": { "system_type": "gameplay-system", "namespace": "itsalive", - "data_version": 3, + "data_version": 4, "network_protocol": 4, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", diff --git a/itsalive/src/test/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRulesSmoke.java b/itsalive/src/test/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRulesSmoke.java new file mode 100644 index 0000000..ec315b1 --- /dev/null +++ b/itsalive/src/test/java/fr/koka99cab/sanctuary26/itsalive/entity/notch/NotchFightRulesSmoke.java @@ -0,0 +1,34 @@ +package fr.koka99cab.sanctuary26.itsalive.entity.notch; + +public final class NotchFightRulesSmoke { + private NotchFightRulesSmoke() { + } + + public static void main(String[] args) { + assertFloat(NotchFightRules.MIN_TITAN_HEALTH, NotchFightRules.titanHealth(-20), + "health below one participant"); + assertFloat(400.0F, NotchFightRules.titanHealth(2), "two participant scaling"); + assertFloat(NotchFightRules.MAX_TITAN_HEALTH, + NotchFightRules.titanHealth(NotchFightRules.MAX_PARTICIPANTS), "maximum scaling"); + assertFloat(NotchFightRules.MAX_TITAN_HEALTH, NotchFightRules.titanHealth(200), + "participant bound"); + if (NotchFightRules.shouldReset(NotchFightRules.RESET_AFTER_EMPTY_TICKS - 1)) { + throw new AssertionError("Fight reset one tick too early"); + } + if (!NotchFightRules.shouldReset(NotchFightRules.RESET_AFTER_EMPTY_TICKS)) { + throw new AssertionError("Fight did not reset at the documented threshold"); + } + if (NotchFightRules.RETURNED_FIREBALL_DAMAGE != 40.0F + || NotchFightRules.Phase.byId(-1) != NotchFightRules.Phase.HUMAN + || NotchFightRules.Phase.byId(1) != NotchFightRules.Phase.TITAN + || NotchFightRules.Phase.byId(2) != NotchFightRules.Phase.DEFEATED) { + throw new AssertionError("Notch phase or reflected-fireball contract changed"); + } + } + + private static void assertFloat(float expected, float actual, String label) { + if (Float.compare(expected, actual) != 0) { + throw new AssertionError(label + ": expected " + expected + ", got " + actual); + } + } +} diff --git a/masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java b/masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java index 2b4da7a..efb2cab 100644 --- a/masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java +++ b/masterkey/src/main/java/fr/koka99cab/sanctuary26/masterkey/command/MasterKeyCommands.java @@ -16,6 +16,7 @@ import fr.koka99cab.sanctuary26.itsalive.creeper.ItsAliveCreeper; import fr.koka99cab.sanctuary26.itsalive.creeper.ItsAliveCreepers; import fr.koka99cab.sanctuary26.itsalive.entity.InfectedEntity; import fr.koka99cab.sanctuary26.itsalive.entity.InfectedVariant; +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchResidentService; import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveEntityTypes; import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceDailyWeather; import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentController; @@ -94,6 +95,7 @@ public final class MasterKeyCommands { .then(creeperCommand()) .then(infectedCommand()) .then(ambianceCommand()) + .then(notchCommand()) .then(SanctuaryProgressionCommands.command()) .then(panoramaCommand()) .then(teleportCommand()) @@ -113,6 +115,42 @@ public final class MasterKeyCommands { ); } + private static com.mojang.brigadier.builder.ArgumentBuilder notchCommand() { + return Commands.literal("notch") + .executes(context -> inspectNotch(context.getSource())) + .then(Commands.literal("inspect") + .executes(context -> inspectNotch(context.getSource()))) + .then(Commands.literal("restore") + .executes(context -> restoreNotch(context.getSource()))); + } + + private static int inspectNotch(CommandSourceStack source) { + NotchResidentService.Inspection inspection = NotchResidentService.inspect(source.getServer()); + if (!inspection.alphaAvailable()) { + source.sendFailure(Component.translatable("command.masterkey.notch.alpha_unavailable")); + return 0; + } + source.sendSuccess(() -> Component.translatable("command.masterkey.notch.inspect", + inspection.loadedResidents(), + inspection.canonicalUuid() == null ? "-" : inspection.canonicalUuid().toString(), + inspection.phase() == null ? "-" : inspection.phase().name().toLowerCase(java.util.Locale.ROOT), + inspection.position() == null ? "-" : inspection.position().toShortString(), + inspection.worldDefeated()), false); + return Command.SINGLE_SUCCESS; + } + + private static int restoreNotch(CommandSourceStack source) { + NotchResidentService.Inspection inspection = NotchResidentService.restore(source.getServer()); + if (!inspection.alphaAvailable() || inspection.canonicalUuid() == null) { + source.sendFailure(Component.translatable("command.masterkey.notch.restore.failure")); + return 0; + } + source.sendSuccess(() -> Component.translatable("command.masterkey.notch.restore.success", + inspection.canonicalUuid().toString(), inspection.loadedResidents(), + inspection.phase().name().toLowerCase(java.util.Locale.ROOT)), true); + return Command.SINGLE_SUCCESS; + } + private static com.mojang.brigadier.builder.ArgumentBuilder ambianceCommand() { var root = Commands.literal("ambiance") .executes(context -> ambianceStatus(context.getSource())) diff --git a/masterkey/src/main/resources/assets/masterkey/lang/en_us.json b/masterkey/src/main/resources/assets/masterkey/lang/en_us.json index 21287b9..50ca499 100644 --- a/masterkey/src/main/resources/assets/masterkey/lang/en_us.json +++ b/masterkey/src/main/resources/assets/masterkey/lang/en_us.json @@ -1,6 +1,10 @@ { "mod.masterkey.description": "Owns administration, diagnostics and developer tools.", - "command.masterkey.help": "MasterKey lab: give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.", + "command.masterkey.help": "MasterKey lab: give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.", + "command.masterkey.notch.alpha_unavailable": "The Alpha dimension is not loaded.", + "command.masterkey.notch.inspect": "Notch: %1$s loaded resident(s), UUID %2$s, phase %3$s, position %4$s, world victory: %5$s.", + "command.masterkey.notch.restore.success": "Notch restored: UUID %1$s, %2$s loaded resident, phase %3$s.", + "command.masterkey.notch.restore.failure": "Notch could not be restored in Alpha.", "command.masterkey.ambiance.status": "Weather: %1$s; lunar night: %2$s (%3$s).", "command.masterkey.ambiance.mode.auto": "automatic", "command.masterkey.ambiance.mode.forced": "forced", diff --git a/masterkey/src/main/resources/assets/masterkey/lang/fr_fr.json b/masterkey/src/main/resources/assets/masterkey/lang/fr_fr.json index 4e0f22e..39612e1 100644 --- a/masterkey/src/main/resources/assets/masterkey/lang/fr_fr.json +++ b/masterkey/src/main/resources/assets/masterkey/lang/fr_fr.json @@ -1,6 +1,10 @@ { "mod.masterkey.description": "Possède l'administration, les diagnostics et les outils de développement.", - "command.masterkey.help": "Laboratoire MasterKey : give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.", + "command.masterkey.help": "Laboratoire MasterKey : give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.", + "command.masterkey.notch.alpha_unavailable": "La dimension Alpha n'est pas chargée.", + "command.masterkey.notch.inspect": "Notch : %1$s résident(s) chargé(s), UUID %2$s, phase %3$s, position %4$s, victoire mondiale : %5$s.", + "command.masterkey.notch.restore.success": "Notch restauré : UUID %1$s, %2$s résident chargé, phase %3$s.", + "command.masterkey.notch.restore.failure": "Impossible de restaurer Notch dans Alpha.", "command.masterkey.ambiance.status": "Météo : %1$s ; nuit lunaire : %2$s (%3$s).", "command.masterkey.ambiance.mode.auto": "automatique", "command.masterkey.ambiance.mode.forced": "forcée", diff --git a/masterkey/src/main/resources/assets/masterkey/lang/ru_ru.json b/masterkey/src/main/resources/assets/masterkey/lang/ru_ru.json index 644bb9a..66e57dd 100644 --- a/masterkey/src/main/resources/assets/masterkey/lang/ru_ru.json +++ b/masterkey/src/main/resources/assets/masterkey/lang/ru_ru.json @@ -1,6 +1,10 @@ { "mod.masterkey.description": "Отвечает за администрирование, диагностику и инструменты разработчика.", - "command.masterkey.help": "Лаборатория MasterKey: give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.", + "command.masterkey.help": "Лаборатория MasterKey: give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.", + "command.masterkey.notch.alpha_unavailable": "Измерение Alpha не загружено.", + "command.masterkey.notch.inspect": "Notch: загружено жителей — %1$s, UUID %2$s, фаза %3$s, позиция %4$s, мировая победа: %5$s.", + "command.masterkey.notch.restore.success": "Notch восстановлен: UUID %1$s, загружено жителей — %2$s, фаза %3$s.", + "command.masterkey.notch.restore.failure": "Не удалось восстановить Notch в Alpha.", "command.masterkey.ambiance.status": "Погода: %1$s; лунная ночь: %2$s (%3$s).", "command.masterkey.ambiance.mode.auto": "автоматически", "command.masterkey.ambiance.mode.forced": "принудительно", diff --git a/masterkey/src/main/resources/fabric.mod.json b/masterkey/src/main/resources/fabric.mod.json index 17af2df..a19bdef 100644 --- a/masterkey/src/main/resources/fabric.mod.json +++ b/masterkey/src/main/resources/fabric.mod.json @@ -23,8 +23,8 @@ "minecraft": "~26.2", "java": ">=25", "fabric-api": ">=0.154.2+26.2", - "itsalive": ">=0.0.0-alpha.1", - "sanctuary": ">=0.0.0-alpha.17", + "itsalive": ">=0.0.0-alpha.37", + "sanctuary": ">=0.0.0-alpha.126", "ambiance": "*" }, "custom": { diff --git a/ouch/README.md b/ouch/README.md index e635688..60e04bb 100644 --- a/ouch/README.md +++ b/ouch/README.md @@ -2,7 +2,7 @@ Ouch possède le combat, les dégâts et la destruction dans Sanctuary 26.2. -## Version courante — `0.0.0-alpha.5` +## Version courante — `0.0.0-alpha.11` La première version active migre les quatre objets du mod historique Gunz dans le namespace unique `ouch:*` et les rassemble dans l’onglet créatif @@ -79,6 +79,15 @@ n’ajoutent ni payload réseau, ni donnée persistante, ni munition enregistré L’Arc bêta réutilise volontairement l’apparence statique du bow vanilla et se fabrique sans forme avec un arc, une redstone et une ficelle. +## Combat de Notch Titan + +Ouch implémente le pont public de combat exposé par It's Alive. Le Titan poursuit +ses participants, annonce visuellement ses attaques puis alterne mêlée, boules de +feu renvoyables et éclairs visuels avec dégâts serveur. Une boule renvoyée par un +joueur inflige 40 dégâts au Titan. Les explosions de projectiles utilisent +`ExplosionInteraction.NONE` et les autres attaques ne modifient aucun bloc ; +seule la transformation initiale, pilotée par l'entité, peut endommager le décor. + ## Compatibilité Les anciens IDs `gunz:*` ne sont pas réenregistrés. Le contrat diff --git a/ouch/build.gradle b/ouch/build.gradle index 5f40e31..07c58e8 100644 --- a/ouch/build.gradle +++ b/ouch/build.gradle @@ -118,7 +118,7 @@ tasks.register("verifyOuch") { def creeperlockRecipe = new JsonSlurper().parse(file("src/main/resources/data/ouch/recipe/creeperlock.json")) def silverEngineeringMigration = new JsonSlurper().parse(rootProject.file( "pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json")) - if (project.version.toString() != "0.0.0-alpha.10" + if (project.version.toString() != "0.0.0-alpha.11" || mitrailletteRecipe.pattern != ["RRP", "IGI", " B "] || mitrailletteRecipe.key != [B: "minecraft:stone_button", G: "minecraft:redstone", I: "anotherworld:silver_ingot", P: "anotherworld:silver_plate", R: "anotherworld:silver_rod"] diff --git a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/client/OuchClient.java b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/client/OuchClient.java index 3dd5d8f..1814a89 100644 --- a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/client/OuchClient.java +++ b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/client/OuchClient.java @@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.ouch.OuchMod; import fr.koka99cab.sanctuary26.ouch.entity.WeatherTntEntity; import fr.koka99cab.sanctuary26.ouch.registry.OuchAbjectionProjectiles; import fr.koka99cab.sanctuary26.ouch.registry.OuchWeatherTnt; +import fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; import net.minecraft.client.renderer.entity.EntityRenderer; @@ -16,6 +17,8 @@ public final class OuchClient implements ClientModInitializer { public void onInitializeClient() { EntityRendererRegistry.register(OuchAbjectionProjectiles.ENTITY, context -> new ThrownItemRenderer<>(context)); + EntityRendererRegistry.register(OuchNotchCombat.FIREBALL, + context -> new ThrownItemRenderer<>(context, 3.0F, true)); registerWeatherRenderer(OuchWeatherTnt.ENTITY); registerWeatherRenderer(OuchWeatherTnt.REDSTONER_COMPAT_ENTITY); registerWeatherRenderer(OuchWeatherTnt.LEGACY_ENTITY); diff --git a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/entity/NotchFireballEntity.java b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/entity/NotchFireballEntity.java new file mode 100644 index 0000000..45ecee4 --- /dev/null +++ b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/entity/NotchFireballEntity.java @@ -0,0 +1,61 @@ +package fr.koka99cab.sanctuary26.ouch.entity; + +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity; +import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchFightRules; +import fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball; +import net.minecraft.world.item.enchantment.EnchantmentHelper; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +/** Large, reflectable Titan fireball whose explosion never changes blocks. */ +public final class NotchFireballEntity extends Fireball { + private static final int MAX_LIFETIME_TICKS = 200; + private static final float DIRECT_HIT_DAMAGE = 10.0F; + private static final float EXPLOSION_POWER = 2.0F; + + public NotchFireballEntity(EntityType type, Level level) { + super(type, level); + } + + public NotchFireballEntity(ServerLevel level, LivingEntity owner, Vec3 direction) { + super(OuchNotchCombat.FIREBALL, owner, direction, level); + } + + @Override + public void tick() { + super.tick(); + if (this.tickCount > MAX_LIFETIME_TICKS) this.discard(); + } + + @Override + protected void onHit(HitResult hitResult) { + super.onHit(hitResult); + if (!(this.level() instanceof ServerLevel level)) return; + level.explode(this, this.getX(), this.getY(), this.getZ(), EXPLOSION_POWER, false, + Level.ExplosionInteraction.NONE); + this.discard(); + } + + @Override + protected void onHitEntity(EntityHitResult hitResult) { + super.onHitEntity(hitResult); + if (!(this.level() instanceof ServerLevel level)) return; + Entity target = hitResult.getEntity(); + Entity owner = this.getOwner(); + if (target instanceof NotchEntity && owner instanceof NotchEntity) return; + var source = this.damageSources().fireball(this, owner); + float damage = target instanceof NotchEntity && owner instanceof Player + ? NotchFightRules.RETURNED_FIREBALL_DAMAGE : DIRECT_HIT_DAMAGE; + if (target.hurtServer(level, source, damage)) { + EnchantmentHelper.doPostAttackEffects(level, target, source); + } + } +} diff --git a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/notch/OuchNotchCombat.java b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/notch/OuchNotchCombat.java new file mode 100644 index 0000000..ccf90f0 --- /dev/null +++ b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/notch/OuchNotchCombat.java @@ -0,0 +1,111 @@ +package fr.koka99cab.sanctuary26.ouch.notch; + +import fr.koka99cab.sanctuary26.itsalive.api.notch.NotchCombatApi; +import fr.koka99cab.sanctuary26.ouch.OuchMod; +import fr.koka99cab.sanctuary26.ouch.entity.NotchFireballEntity; +import net.minecraft.core.Registry; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LightningBolt; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** Ouch-owned implementation of the server-authoritative Notch attack bridge. */ +public final class OuchNotchCombat implements NotchCombatApi.Engine { + private static final Identifier FIREBALL_ID = OuchMod.id("notch_fireball"); + private static final ResourceKey> FIREBALL_KEY = ResourceKey.create( + Registries.ENTITY_TYPE, FIREBALL_ID); + public static final EntityType FIREBALL = Registry.register( + BuiltInRegistries.ENTITY_TYPE, FIREBALL_ID, + EntityType.Builder.of(NotchFireballEntity::new, MobCategory.MISC) + .sized(1.0F, 1.0F) + .clientTrackingRange(64) + .updateInterval(1) + .noSave() + .noLootTable() + .build(FIREBALL_KEY)); + private static boolean initialized; + + private OuchNotchCombat() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + NotchCombatApi.register(new OuchNotchCombat()); + OuchMod.LOGGER.debug("[{}] Registered the Notch Titan combat engine.", OuchMod.DISPLAY_NAME); + } + + @Override + public void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, + NotchCombatApi.Attack attack) { + if (attack == NotchCombatApi.Attack.FIREBALL) { + level.sendParticles(ParticleTypes.FLAME, boss.getX(), boss.getEyeY(), boss.getZ(), + 28, 0.7D, 1.0D, 0.7D, 0.035D); + level.playSound(null, boss.blockPosition(), SoundEvents.BLAZE_SHOOT, SoundSource.HOSTILE, + 0.9F, 0.55F); + } else if (attack == NotchCombatApi.Attack.LIGHTNING) { + level.sendParticles(ParticleTypes.ELECTRIC_SPARK, target.getX(), target.getY() + 0.1D, + target.getZ(), 42, 1.25D, 0.06D, 1.25D, 0.08D); + level.playSound(null, target.blockPosition(), SoundEvents.TRIDENT_THUNDER.value(), + SoundSource.HOSTILE, 1.0F, 1.35F); + } + } + + @Override + public void execute(ServerLevel level, LivingEntity boss, LivingEntity target, + NotchCombatApi.Attack attack) { + switch (attack) { + case MELEE -> melee(level, boss, target); + case FIREBALL -> fireball(level, boss, target); + case LIGHTNING -> lightning(level, boss, target); + } + } + + private static void melee(ServerLevel level, LivingEntity boss, LivingEntity target) { + boss.swing(InteractionHand.MAIN_HAND); + if (!target.hurtServer(level, level.damageSources().mobAttack(boss), 18.0F)) return; + pushAway(boss, target, 1.4D, 0.48D); + level.sendParticles(ParticleTypes.EXPLOSION, target.getX(), target.getY() + 1.0D, + target.getZ(), 4, 0.35D, 0.55D, 0.35D, 0.02D); + } + + private static void fireball(ServerLevel level, LivingEntity boss, LivingEntity target) { + Vec3 origin = boss.getEyePosition().add(boss.getLookAngle().scale(1.5D)); + Vec3 aim = target.getEyePosition().subtract(origin).normalize(); + NotchFireballEntity projectile = new NotchFireballEntity(level, boss, aim); + projectile.setPos(origin); + level.addFreshEntity(projectile); + } + + private static void lightning(ServerLevel level, LivingEntity boss, LivingEntity target) { + LightningBolt lightning = EntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.TRIGGERED); + if (lightning != null) { + lightning.setVisualOnly(true); + lightning.setPos(target.position()); + level.addFreshEntity(lightning); + } + if (target.hurtServer(level, level.damageSources().lightningBolt(), 7.0F)) { + pushAway(boss, target, 1.0D, 0.35D); + } + } + + private static void pushAway(LivingEntity boss, LivingEntity target, double strength, double lift) { + Vec3 horizontal = target.position().subtract(boss.position()).multiply(1.0D, 0.0D, 1.0D); + if (horizontal.lengthSqr() < 1.0E-6D) horizontal = boss.getLookAngle().multiply(1.0D, 0.0D, 1.0D); + horizontal = horizontal.normalize().scale(strength); + target.push(horizontal.x, lift, horizontal.z); + } +} diff --git a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchRegistries.java b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchRegistries.java index 2352350..f4a2c47 100644 --- a/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchRegistries.java +++ b/ouch/src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchRegistries.java @@ -1,6 +1,7 @@ package fr.koka99cab.sanctuary26.ouch.registry; import fr.koka99cab.sanctuary26.ouch.OuchMod; +import fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat; /** * Single registration entry point for the Ouch namespace. @@ -20,6 +21,7 @@ public final class OuchRegistries { OuchItems.initialize(); OuchWeatherTnt.initialize(); OuchAbjectionProjectiles.initialize(); + OuchNotchCombat.initialize(); OuchCreativeTab.initialize(); OuchMod.LOGGER.debug("[{}] Weapon registry hub initialized.", OuchMod.DISPLAY_NAME); } diff --git a/ouch/src/main/resources/fabric.mod.json b/ouch/src/main/resources/fabric.mod.json index 77e07cf..a33985c 100644 --- a/ouch/src/main/resources/fabric.mod.json +++ b/ouch/src/main/resources/fabric.mod.json @@ -26,7 +26,8 @@ "anotherworld": ">=0.0.0-alpha.46", "ambiance": ">=0.0.0-alpha.12", "onlyfun": ">=0.0.0-alpha.23", - "sanctuary": ">=0.0.0-alpha.110" + "itsalive": ">=0.0.0-alpha.37", + "sanctuary": ">=0.0.0-alpha.126" }, "custom": { "sanctuary26": { diff --git a/sanctuary/ALPHA.md b/sanctuary/ALPHA.md index c92293a..11b368c 100644 --- a/sanctuary/ALPHA.md +++ b/sanctuary/ALPHA.md @@ -36,3 +36,18 @@ l'arrivée sûre du gameplay normal. Les textures historiques facultatives sont décrites dans [`ambiance/GOLDEN_DAYS.md`](../ambiance/GOLDEN_DAYS.md). Elles ne font pas partie de Sanctuary. + +## Résident et jalon mondial + +It's Alive place exactement un `itsalive:notch` persistant dans la résidence. +Son UUID canonique est sauvegardé dans Alpha, les doublons chargés sont retirés +et `/masterkey notch restore` permet de le recréer ou le ramener chez lui. Un +alias caché `portals:notch` conserve le chargement des anciennes sauvegardes. + +Le premier coup volontaire d'un joueur déclenche le combat de Notch Titan. +Ouch possède les attaques et les dégâts ; seule l'explosion de transformation +peut casser des blocs. La victoire est enregistrée une seule fois dans les +données globales Sanctuary, même après redémarrage ou restauration de l'entité. +Les participants reçoivent le jalon `sanctuary:notch_defeated` et un marqueur +de boss dans leur Atlas si Alpha y était déjà découverte. Le drapeau de +récompense initiale reste séparé et monotone pour la future étape CARPET-01. diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index 6d761c3..2d4f888 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -132,6 +132,14 @@ tasks.register("alphaIndevGeneratorSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.AlphaIndevGeneratorSmoke" } +tasks.register("notchVictoryStateSmoke", JavaExec) { + group = "verification" + description = "Checks the monotone, bounded and codec-stable world-wide Notch victory." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.data.NotchVictoryStateSmoke" +} + tasks.register("verifyBlackMarket") { group = "verification" description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery." @@ -167,7 +175,7 @@ tasks.register("verifyBlackMarket") { def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text - if (project.version.toString() != "0.0.0-alpha.125" + if (project.version.toString() != "0.0.0-alpha.126" || !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2") || !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing") || !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5") @@ -1168,7 +1176,7 @@ tasks.register("verifySanctuary") { } def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json")) - if (manifest.custom?.sanctuary26?.data_version != 11 + if (manifest.custom?.sanctuary26?.data_version != 12 || manifest.custom?.sanctuary26?.network_protocol != 22 || manifest.depends?.jei != ">=30.24.0.176" || manifest.depends?."fabric-api" != ">=0.155.0+26.2" @@ -2875,7 +2883,7 @@ tasks.register("verifyShopCurrencyRowsRelease") { "src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text def shop = file( "src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text - if (project.version.toString() != "0.0.0-alpha.125" + if (project.version.toString() != "0.0.0-alpha.126" || migration.target?.modules?.sanctuary != "0.0.0-alpha.117" || migration.shop_currency_inventory?.all_unlocked_rows_counted != true || migration.shop_currency_inventory?.overflow_rows_counted != true @@ -3041,7 +3049,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || 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 @@ -3079,7 +3087,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || 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] @@ -3110,7 +3118,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || migration.target?.pack_version != "26.2.0-alpha.195" || migration.target?.modules?.sanctuary != "0.0.0-alpha.111" || migration.compatibility?.sanctuary_data_version != 11 @@ -3141,7 +3149,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || migration.target?.modules?.sanctuary != "0.0.0-alpha.112" || migration.mailbox?.id != "sanctuary:delivery_box" || migration.mailbox?.horizontal_facing != true @@ -3169,7 +3177,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || !transfer.contains("shouldAppendOverflow") || !transfer.contains("isVanillaStorageRange") || !transfer.contains("hotbarStart") @@ -3215,7 +3223,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || migration.target?.modules?.sanctuary != "0.0.0-alpha.113" || migration.quest_requests?.save_key != "quest_requests" || migration.quest_requests?.data_version != 1 @@ -3273,7 +3281,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || 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" @@ -3342,7 +3350,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.125" + if (project.version.toString() != "0.0.0-alpha.126" || 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" @@ -3414,7 +3422,7 @@ tasks.register("verifyAlphaIndev") { def locales = ["fr_fr", "en_us", "ru_ru"].collect { new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json")) } - if (project.version.toString() != "0.0.0-alpha.125" + if (project.version.toString() != "0.0.0-alpha.126" || migration.target?.modules?.sanctuary != "0.0.0-alpha.122" || migration.alpha_world?.dimension != "sanctuary:alpha" || migration.alpha_world?.legacy_dimension != "portals:alpha" @@ -3437,6 +3445,7 @@ tasks.register("verifyAlphaIndev") { } tasks.named("check") { + dependsOn tasks.named("notchVictoryStateSmoke") dependsOn tasks.named("verifySanctuary") dependsOn tasks.named("verifyDeliveryBoxOrientation") dependsOn tasks.named("verifyQuestRequests") diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java index 0322530..6842461 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java @@ -34,7 +34,7 @@ import org.slf4j.LoggerFactory; public final class SanctuaryMod implements ModInitializer { public static final String MOD_ID = "sanctuary"; public static final String DISPLAY_NAME = "Sanctuary"; - public static final int CURRENT_DATA_VERSION = 11; + public static final int CURRENT_DATA_VERSION = 12; public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public SanctuaryMod() { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/AtlasLandmark.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/AtlasLandmark.java index 7ecf6ca..7434041 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/AtlasLandmark.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/AtlasLandmark.java @@ -28,7 +28,7 @@ public record AtlasLandmark( Codec.LONG.optionalFieldOf("discovered_at", 0L).forGetter(AtlasLandmark::discoveredAt), Codec.BOOL.optionalFieldOf("manual", false).forGetter(AtlasLandmark::manual) ).apply(instance, AtlasLandmark::new)); - public static final Set AUTOMATIC_TYPES = Set.of("danger", "mine", "portal", "chest", "spawner"); + public static final Set AUTOMATIC_TYPES = Set.of("danger", "mine", "portal", "chest", "spawner", "boss"); public static final Set MANUAL_TYPES = Set.of("home", "tree"); public AtlasLandmark { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/SanctuaryAtlasLandmarks.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/SanctuaryAtlasLandmarks.java index 9d726e4..4387a09 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/SanctuaryAtlasLandmarks.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/atlas/SanctuaryAtlasLandmarks.java @@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.sanctuary.data.SanctuaryData; import fr.koka99cab.sanctuary26.sanctuary.network.SanctuaryNetworking; import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryPlayerProgress; import java.util.Locale; +import java.util.UUID; import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.minecraft.core.BlockPos; @@ -12,6 +13,8 @@ import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; import net.minecraft.tags.BlockTags; import net.minecraft.world.entity.monster.Enemy; import net.minecraft.world.level.block.Blocks; @@ -56,6 +59,26 @@ public final class SanctuaryAtlasLandmarks { return true; } + /** Adds a server-owned landmark only when the player already knows its dimension. */ + public static boolean recordTrusted(MinecraftServer server, UUID playerId, + ResourceKey dimension, BlockPos pos, String type) { + if (!AtlasLandmark.AUTOMATIC_TYPES.contains(type)) return false; + SanctuaryData data = SanctuaryData.get(server); + SanctuaryPlayerProgress progress = data.player(playerId); + String dimensionId = dimension.identifier().toString(); + if (!progress.dimensions().contains(dimensionId) + || progress.hasAtlasLandmarkNear(type, dimensionId, pos, 8)) return false; + ServerLevel level = server.getLevel(dimension); + long gameTime = level == null ? 0L : level.getGameTime(); + AtlasLandmark landmark = new AtlasLandmark(type, dimensionId, pos.getX(), pos.getY(), pos.getZ(), + "", gameTime, false); + if (!progress.recordAtlasLandmark(landmark)) return false; + data.markChanged(); + ServerPlayer online = server.getPlayerList().getPlayer(playerId); + if (online != null) SanctuaryNetworking.sync(online); + return true; + } + private static void tick(MinecraftServer server) { if (++tickCounter < SCAN_INTERVAL_TICKS) return; tickCounter = 0; diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java new file mode 100644 index 0000000..df0535d --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/notch/SanctuaryNotchMilestone.java @@ -0,0 +1,99 @@ +package fr.koka99cab.sanctuary26.sanctuary.api.notch; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks; +import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld; +import fr.koka99cab.sanctuary26.sanctuary.data.NotchVictoryData; +import fr.koka99cab.sanctuary26.sanctuary.data.NotchVictoryState; +import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService; +import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.UUID; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +/** Public server-only resolver for the monotone {@code notch_defeated} milestone. */ +public final class SanctuaryNotchMilestone { + public static final Identifier NOTCH_DEFEATED = SanctuaryMod.id("notch_defeated"); + private static final String SOURCE = "itsalive"; + + private SanctuaryNotchMilestone() { + } + + public static boolean isDefeated(MinecraftServer server) { + return NotchVictoryData.get(server).snapshot().defeated(); + } + + public static NotchVictoryState snapshot(MinecraftServer server) { + return NotchVictoryData.get(server).snapshot(); + } + + /** Records a real server-side defeat and credits every bounded canonical participant once. */ + public static VictoryResult recordVictory(ServerLevel alphaLevel, Collection candidates) { + if (!alphaLevel.dimension().equals(SanctuaryDimensions.ALPHA)) { + throw new IllegalArgumentException("Notch can only be defeated in sanctuary:alpha"); + } + List participants = sanitizeParticipants(alphaLevel, candidates); + return record(alphaLevel, participants, false, true); + } + + /** Imports the defeated phase from the historical portals:notch entity without duplicating rewards. */ + public static VictoryResult migrateLegacyVictory(ServerLevel alphaLevel, boolean rewardAlreadyClaimed) { + if (!alphaLevel.dimension().equals(SanctuaryDimensions.ALPHA)) { + throw new IllegalArgumentException("Legacy Notch can only be migrated from sanctuary:alpha"); + } + return record(alphaLevel, List.of(), rewardAlreadyClaimed, false); + } + + /** Atomically reserves the one canonical initial reward for CARPET-01. */ + public static boolean claimInitialReward(MinecraftServer server) { + return NotchVictoryData.get(server).claimInitialReward(); + } + + private static VictoryResult record(ServerLevel alphaLevel, List participants, + boolean rewardAlreadyClaimed, boolean announce) { + NotchVictoryData data = NotchVictoryData.get(alphaLevel.getServer()); + boolean newlyDefeated = data.recordDefeat(participants, alphaLevel.getGameTime(), rewardAlreadyClaimed); + NotchVictoryState state = data.snapshot(); + BlockPos home = SanctuaryAlphaWorld.residence(alphaLevel.getSeed()); + for (UUID participant : state.participants()) { + SanctuaryProgressionService.recordExternalAt(alphaLevel.getServer(), participant, + NOTCH_DEFEATED, SanctuaryDimensions.ALPHA, home, alphaLevel.getGameTime(), SOURCE); + SanctuaryAtlasLandmarks.recordTrusted(alphaLevel.getServer(), participant, + SanctuaryDimensions.ALPHA, home, "boss"); + } + if (newlyDefeated && announce) { + alphaLevel.getServer().getPlayerList().broadcastSystemMessage( + Component.translatable("message.sanctuary.notch_defeated").withStyle(ChatFormatting.GOLD), false); + } + return new VictoryResult(newlyDefeated, state.participants(), state.initialRewardClaimed()); + } + + private static List sanitizeParticipants(ServerLevel level, Collection candidates) { + if (candidates == null || candidates.isEmpty()) return List.of(); + LinkedHashSet result = new LinkedHashSet<>(); + for (UUID candidate : candidates) { + if (result.size() >= NotchVictoryState.MAX_PARTICIPANTS) break; + ServerPlayer player = level.getServer().getPlayerList().getPlayer(candidate); + if (player != null && player.level() == level && !player.isSpectator() + && SanctuaryAlphaWorld.containsHorizontal(player.blockPosition())) { + result.add(candidate); + } + } + return List.copyOf(result); + } + + public record VictoryResult(boolean newlyDefeated, List participants, + boolean initialRewardClaimed) { + public VictoryResult { + participants = List.copyOf(participants); + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryAtlasMarkerLayer.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryAtlasMarkerLayer.java index 992c254..a7b0e49 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryAtlasMarkerLayer.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryAtlasMarkerLayer.java @@ -101,6 +101,7 @@ final class SanctuaryAtlasMarkerLayer { case "portal" -> 0xFF9D46FF; case "chest" -> 0xFFC88B3A; case "spawner" -> 0xFF6E7EA8; + case "boss" -> 0xFFFF5A45; case "tree" -> 0xFF5FD34F; default -> 0xFFE8E8DD; }; diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java new file mode 100644 index 0000000..ecb4851 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryData.java @@ -0,0 +1,60 @@ +package fr.koka99cab.sanctuary26.sanctuary.data; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import java.util.Collection; +import java.util.UUID; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** SavedData wrapper for the world-wide Notch victory and future unique reward claim. */ +public final class NotchVictoryData extends SavedData { + private static final SavedDataType TYPE = new SavedDataType<>( + SanctuaryMod.id("notch_victory"), + NotchVictoryData::new, + NotchVictoryState.CODEC.xmap(NotchVictoryData::new, data -> data.state), + DataFixTypes.LEVEL + ); + + private NotchVictoryState state; + + public NotchVictoryData() { + this(NotchVictoryState.empty()); + } + + private NotchVictoryData(NotchVictoryState state) { + this.state = state == null ? NotchVictoryState.empty() : state; + } + + public static NotchVictoryData get(MinecraftServer server) { + ServerLevel overworld = server.getLevel(Level.OVERWORLD); + if (overworld == null) { + throw new IllegalStateException("The Overworld is required for the global Notch milestone"); + } + return overworld.getDataStorage().computeIfAbsent(TYPE); + } + + public NotchVictoryState snapshot() { + return state; + } + + public boolean recordDefeat(Collection participants, long gameTime, boolean rewardAlreadyClaimed) { + NotchVictoryState updated = state.defeat(participants, gameTime, rewardAlreadyClaimed); + if (updated.equals(state)) return false; + boolean newlyDefeated = !state.defeated() && updated.defeated(); + state = updated; + setDirty(); + return newlyDefeated; + } + + public boolean claimInitialReward() { + NotchVictoryState updated = state.claimInitialReward(); + if (updated.equals(state)) return false; + state = updated; + setDirty(); + return true; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java new file mode 100644 index 0000000..82693ee --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryState.java @@ -0,0 +1,77 @@ +package fr.koka99cab.sanctuary26.sanctuary.data; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.UUID; + +/** Immutable model behind the monotone world-wide Notch milestone. */ +public record NotchVictoryState( + int dataVersion, + boolean defeated, + long defeatedAt, + List participantIds, + boolean initialRewardClaimed +) { + public static final int DATA_VERSION = 1; + public static final int MAX_PARTICIPANTS = 16; + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(NotchVictoryState::dataVersion), + Codec.BOOL.optionalFieldOf("notch_defeated", false).forGetter(NotchVictoryState::defeated), + Codec.LONG.optionalFieldOf("defeated_at", 0L).forGetter(NotchVictoryState::defeatedAt), + Codec.STRING.listOf().optionalFieldOf("participants", List.of()).forGetter(NotchVictoryState::participantIds), + Codec.BOOL.optionalFieldOf("initial_reward_claimed", false) + .forGetter(NotchVictoryState::initialRewardClaimed) + ).apply(instance, NotchVictoryState::new)); + + public NotchVictoryState { + dataVersion = DATA_VERSION; + defeatedAt = Math.max(0L, defeatedAt); + participantIds = normalizeStrings(participantIds); + initialRewardClaimed = defeated && initialRewardClaimed; + } + + public static NotchVictoryState empty() { + return new NotchVictoryState(DATA_VERSION, false, 0L, List.of(), false); + } + + public NotchVictoryState defeat(Collection participants, long gameTime, boolean rewardAlreadyClaimed) { + if (defeated) { + return rewardAlreadyClaimed && !initialRewardClaimed + ? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true) + : this; + } + List ids = participants == null ? List.of() : participants.stream() + .filter(java.util.Objects::nonNull) + .map(UUID::toString) + .toList(); + return new NotchVictoryState(DATA_VERSION, true, Math.max(0L, gameTime), ids, + rewardAlreadyClaimed); + } + + public NotchVictoryState claimInitialReward() { + return defeated && !initialRewardClaimed + ? new NotchVictoryState(DATA_VERSION, true, defeatedAt, participantIds, true) + : this; + } + + public List participants() { + return participantIds.stream().map(UUID::fromString).toList(); + } + + private static List normalizeStrings(Collection values) { + if (values == null || values.isEmpty()) return List.of(); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String value : values) { + if (normalized.size() >= MAX_PARTICIPANTS) break; + try { + normalized.add(UUID.fromString(value).toString()); + } catch (IllegalArgumentException ignored) { + // Invalid legacy entries are ignored instead of poisoning the save. + } + } + return List.copyOf(normalized); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionService.java index a92018f..f3c02b2 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionService.java @@ -6,12 +6,17 @@ import fr.koka99cab.sanctuary26.sanctuary.network.SanctuaryNetworking; import fr.koka99cab.sanctuary26.sanctuary.network.PurchaseProgressionPayload; import java.util.Collection; import java.util.List; +import java.time.Instant; +import java.util.UUID; +import net.minecraft.core.BlockPos; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.minecraft.advancements.AdvancementHolder; import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.network.chat.Component; +import net.minecraft.world.level.Level; /** Low-frequency server tracker and the mutation API used by MasterKey and future systems. */ public final class SanctuaryProgressionService { @@ -132,6 +137,25 @@ public final class SanctuaryProgressionService { return true; } + /** Records a trusted located event for an online or offline participant. */ + public static boolean recordExternalAt(MinecraftServer server, UUID playerId, Identifier eventId, + ResourceKey dimension, BlockPos pos, long gameTime, String source) { + SanctuaryData data = SanctuaryData.get(server); + SanctuaryPlayerProgress progress = data.player(playerId); + ProgressMarker marker = new ProgressMarker(DiscoveryKind.EXTERNAL.serializedName(), eventId.toString(), + dimension.identifier().toString(), pos.getX(), pos.getY(), pos.getZ(), Math.max(0L, gameTime), + Instant.now().toEpochMilli(), 0, source, true); + if (!progress.recordMarker(marker)) return false; + data.recordGlobalFirst(marker); + data.markChanged(); + ServerPlayer online = server.getPlayerList().getPlayer(playerId); + if (online != null) { + SanctuaryNetworking.sync(online); + SanctuaryProgressionEvents.RECORDED.invoker().onRecorded(online, marker, progress.stardust()); + } + return true; + } + public static SanctuaryProgressSnapshot snapshot(ServerPlayer player) { return SanctuaryProgressSnapshot.create(player, SanctuaryData.get(player.level().getServer())); } diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 88ab03e..c93645c 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -514,5 +514,7 @@ "item.sanctuary.minecon_2011_cape": "Minecon 2011 Cape", "item.sanctuary.mojang_classic_cape": "Mojang Classic Cape", "item.sanctuary.blue_scroll_cape": "Blue Scroll Cape", - "dimension.sanctuary.alpha": "Alpha / Indev Island" + "dimension.sanctuary.alpha": "Alpha / Indev Island", + "screen.sanctuary.atlas.landmark.boss": "Defeated boss", + "message.sanctuary.notch_defeated": "Notch Titan has been defeated. The victory is now recorded in this world." } diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 2c14b60..2f34034 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -514,5 +514,7 @@ "item.sanctuary.minecon_2011_cape": "Cape Minecon 2011", "item.sanctuary.mojang_classic_cape": "Cape Mojang classique", "item.sanctuary.blue_scroll_cape": "Cape parchemin bleu", - "dimension.sanctuary.alpha": "Île Alpha / Indev" + "dimension.sanctuary.alpha": "Île Alpha / Indev", + "screen.sanctuary.atlas.landmark.boss": "Boss vaincu", + "message.sanctuary.notch_defeated": "Notch Titan a été vaincu. La victoire est désormais inscrite dans ce monde." } diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json index 927d305..b726ea5 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json @@ -470,5 +470,7 @@ "item.sanctuary.minecon_2011_cape": "Плащ Minecon 2011", "item.sanctuary.mojang_classic_cape": "Классический плащ Mojang", "item.sanctuary.blue_scroll_cape": "Плащ синего свитка", - "dimension.sanctuary.alpha": "Остров Альфа / Indev" + "dimension.sanctuary.alpha": "Остров Альфа / Indev", + "screen.sanctuary.atlas.landmark.boss": "Побеждённый босс", + "message.sanctuary.notch_defeated": "Нотч-Титан побеждён. Победа навсегда записана в этом мире." } diff --git a/sanctuary/src/main/resources/fabric.mod.json b/sanctuary/src/main/resources/fabric.mod.json index 4c86231..2e3ebf3 100644 --- a/sanctuary/src/main/resources/fabric.mod.json +++ b/sanctuary/src/main/resources/fabric.mod.json @@ -32,7 +32,7 @@ "sanctuary26": { "system_type": "gameplay-system", "namespace": "sanctuary", - "data_version": 11, + "data_version": 12, "network_protocol": 22, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java new file mode 100644 index 0000000..cdd1755 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/data/NotchVictoryStateSmoke.java @@ -0,0 +1,40 @@ +package fr.koka99cab.sanctuary26.sanctuary.data; + +import com.mojang.serialization.JsonOps; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class NotchVictoryStateSmoke { + private NotchVictoryStateSmoke() { + } + + public static void main(String[] args) { + UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002"); + NotchVictoryState defeated = NotchVictoryState.empty().defeat( + List.of(first, second, first), 1234L, false); + if (!defeated.defeated() || defeated.defeatedAt() != 1234L + || !defeated.participants().equals(List.of(first, second)) + || defeated.initialRewardClaimed()) { + throw new AssertionError("First Notch victory was not normalized"); + } + NotchVictoryState replayed = defeated.defeat(List.of(UUID.randomUUID()), 9999L, false); + if (replayed != defeated) throw new AssertionError("World victory is not monotone"); + NotchVictoryState claimed = defeated.claimInitialReward(); + if (!claimed.initialRewardClaimed() || claimed.claimInitialReward() != claimed) { + throw new AssertionError("Unique reward claim is not monotone"); + } + ArrayList malformed = new ArrayList<>(); + malformed.add("invalid"); + for (int index = 0; index < 40; index++) malformed.add(new UUID(0L, index + 10L).toString()); + NotchVictoryState bounded = new NotchVictoryState(0, true, -5L, malformed, true); + if (bounded.dataVersion() != NotchVictoryState.DATA_VERSION || bounded.defeatedAt() != 0L + || bounded.participantIds().size() != NotchVictoryState.MAX_PARTICIPANTS) { + throw new AssertionError("Legacy Notch victory normalization is unbounded"); + } + var encoded = NotchVictoryState.CODEC.encodeStart(JsonOps.INSTANCE, claimed).getOrThrow(); + NotchVictoryState decoded = NotchVictoryState.CODEC.parse(JsonOps.INSTANCE, encoded).getOrThrow(); + if (!decoded.equals(claimed)) throw new AssertionError("Notch victory codec did not round-trip"); + } +} -- 2.54.0