Compare commits

...
Author SHA1 Message Date
koka b39c271c96 feat(backroom): add persistent lost-item dimension
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-01 14:30:42 +02:00
koka a16156b005 Merge pull request 'feat: ajouter une cape craftable décorée par bannière' (#126) from feature/craftable-banner-cape into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #126
2026-08-31 11:44:49 +00:00
koka 2518c20071 feat(sanctuary): add craftable banner cape
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 13:25:27 +02:00
koka b7497c81d1 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.225' (#125) from release/26.2.0-alpha.225 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #125
2026-08-31 09:50:25 +00:00
koka 97638e0fa2 release: prepare Sanctuary 26.2.0-alpha.225
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 11:46:39 +02:00
koka a071a0b746 fix(magic-carpet): restore the Notch Totem texture 2026-08-31 11:46:24 +02:00
koka 6fcc56552c fix(canaplia): preserve crop variants through growth 2026-08-31 11:46:17 +02:00
koka 66944ec492 fix(companions): suppress loot and quest progress 2026-08-31 11:46:02 +02:00
koka b513ab1262 fix(anotherworld): require a vault before raids 2026-08-31 11:45:46 +02:00
koka b2222c5d33 Merge pull request 'release: publier Sanctuary 26.2.0-alpha.224' (#122) from release/26.2.0-alpha.224 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #122
2026-08-31 05:17:01 +00:00
koka 2d0be21c5b release: prepare Sanctuary 26.2.0-alpha.224
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 07:13:29 +02:00
koka a0b0788869 Merge pull request 'feat: livrer et migrer le Magic Carpet après Notch' (#121) from integration/magic-carpet-reward into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #121
2026-08-31 04:59:18 +00:00
koka 106d730dd8 feat: deliver and migrate the Magic Carpet
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 06:45:28 +02:00
koka 21dd089486 Merge pull request 'feat: ajouter Notch et son combat dans Alpha' (#119) from integration/notch-encounter into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #119
2026-08-31 04:15:54 +00:00
koka e3b6c6ed01 Merge pull request 'fix(sanctuary): trier coffres et barrels et garder la hotbar en bas' (#120) from fix/issue-118-container-sort into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #120
2026-08-31 04:15:42 +00:00
ChrisM-PekandCursor 7387096d98 chore(sanctuary): aligner la verif alpha.223 sur le tri des conteneurs
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:54:43 +02:00
ChrisM-PekandCursor 457f91b92e fix(sanctuary): trier coffres et barrels et garder la hotbar en bas
Le clic molette range aussi les conteneurs de stockage, et la hotbar reste sur la derniere ligne a la connexion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 05:52:17 +02:00
koka f283c1705b feat: add Notch Alpha encounter
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 05:42:19 +02:00
koka 6ee5cdc2fb Merge pull request 'release: tester les dernières PR dans Packwiz alpha.223' (#117) from release/26.2.0-alpha.223 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #117
2026-08-31 03:15:43 +00:00
koka 87428c5f0d release: test latest PRs in Packwiz alpha.223
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 05:13:46 +02:00
koka 0599bf6885 Merge pull request 'feat(sanctuary): ajouter les modes du suivi de quêtes' (#107) from feature/quest-tracker-display-modes into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #107
2026-08-31 03:02:03 +00:00
koka 624f441578 Merge pull request 'feat(sanctuary): trier le stockage sans deplacer la hotbar' (#112) from feature/inventory-sorter into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #112
2026-08-31 03:01:13 +00:00
ChrisM-PekandCursor d6ca1140b5 feat(sanctuary): trier le stockage sans deplacer la hotbar
Verification 26.2 / check (pull_request) Canceled after 0s
Le clic molette range les rangées débloquées depuis le haut, fusionne les piles identiques et laisse la hotbar, l'armure et les slots spéciaux intacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 04:20:33 +02:00
koka 53fa32bbc5 feat(sanctuary): ajouter les modes du suivi de quetes
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 03:27:36 +02:00
koka 7a2f697024 Merge pull request 'release: publier les dernières PR dans Packwiz alpha.222' (#100) from release/26.2.0-alpha.222 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #100
2026-08-30 13:33:43 +00:00
koka 4d0750e993 release: update Packwiz to alpha.222
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:29:24 +02:00
koka c49673801f Merge pull request 'fix(sanctuary): simplifier le suivi de quetes' (#99) from fix/quest-tracker-transparent into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #99
2026-08-30 13:15:18 +00:00
koka 5386c7ac8a fix(sanctuary): simplifier le suivi de quetes
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:09:01 +02:00
koka eb25625a45 Merge pull request 'feat(sanctuary): ajouter un suivi d'avancement des quetes' (#86) from feature/quest-tracker-hud into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #86
2026-08-30 13:00:21 +00:00
ChrisM-PekandCursor 05c445eaeb Merge origin/main into feature/quest-tracker-hud
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:50:24 +02:00
koka 788928f2d9 Merge pull request 'feat(redstoner): rechercher et vider sans glissement dans le terminal' (#97) from feature/storage-terminal-search into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #97
2026-08-30 08:06:20 +00:00
koka 5d3db9bb42 chore(redstoner): defer packwiz refresh
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 10:01:50 +02:00
koka fd528abb9d feat(redstoner): add storage terminal keyboard search 2026-08-30 10:00:31 +02:00
koka 40acccc818 feat(redstoner): add storage terminal keyboard search
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 08:43:17 +02:00
koka 62c8c87eb0 Merge pull request 'fix(ambiance): restaurer la vitesse native du disque blanc (alpha.221)' (#96) from fix/white-disc-native-rate into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #96
2026-08-30 06:03:00 +00:00
koka b5dab3b5f6 chore(release): prepare alpha.221 native audio
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 07:07:36 +02:00
koka 90790161db Merge remote-tracking branch 'origin/feature/storage-terminal-menu' into fix/white-disc-native-rate 2026-08-30 06:58:50 +02:00
koka e8dee9b7f8 feat(redstoner): integrate storage terminal menu
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 06:55:18 +02:00
koka 6fc93ca732 fix(ambiance): preserve white disc native audio rate 2026-08-30 06:51:22 +02:00
koka 8787969347 Merge pull request 'feat(alpha): intégrer l’île Indev, son portail et ses textures locales' (#93) from integration/alpha-indev into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #93
2026-08-30 03:10:56 +00:00
koka 01a82c3c93 merge: align Alpha Indev release with alpha.219
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 04:17:04 +02:00
koka f23f741a9d Merge pull request 'release: alpha.217 audio et alpha.218 terminal de stockage' (#94) from release/26.2.0-alpha.218 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #94
2026-08-30 02:08:32 +00:00
koka 4714c69255 chore(release): prepare Alpha Indev integration
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:59:19 +02:00
koka c03e5d8555 feat(ambiance): scope Golden Days to Alpha (#46) 2026-08-30 03:59:10 +02:00
koka 9abf6bbea7 feat(iliketomoveit): activate Alpha portal (#45) 2026-08-30 03:59:05 +02:00
koka 9c09664778 feat(sanctuary): port finite Indev island (#44) 2026-08-30 03:59:00 +02:00
koka 87f11f6127 release: sequence audio alpha.217 and terminal alpha.218
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:55:28 +02:00
koka c04cd78ae9 test(ambiance): verify white disc playback cadence 2026-08-30 03:48:15 +02:00
koka 7ae99a12a8 Merge pull request 'feat(redstoner): ajouter le terminal de stockage' (#91) from feature/storage-terminal into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #91
2026-08-30 01:27:15 +00:00
koka 1b1f3a791d feat(redstoner): add storage terminal
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 03:13:48 +02:00
koka 10b1cd9c5e Merge pull request 'Fix white disc audio cadence and release alpha.216' (#89) from fix/white-disc-audio-quality into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #89
2026-08-29 23:26:10 +00:00
koka ef18e0ae32 Fix white disc audio cadence and release alpha.216
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 01:19:02 +02:00
koka 1c2e000da3 Merge pull request 'release: Sanctuary 26.2.0-alpha.215 — disque blanc stabilisé' (#88) from release/26.2.0-alpha.215 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #88
2026-08-29 22:54:40 +00:00
koka 9e35c244ec release: prepare 26.2.0-alpha.215
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 00:52:59 +02:00
koka e82cb33407 Merge pull request 'fix(ambiance): stabiliser la lecture du disque blanc' (#87) from fix/white-disc-playback-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #87
2026-08-29 22:46:44 +00:00
ChrisM-PekandCursor 6e58d44c71 feat(sanctuary): assister les IDs de quete et limiter le tracker aux quetes acceptees
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 00:44:38 +02:00
ChrisM-PekandCursor d45afc7767 feat(sanctuary): ajouter un suivi d'avancement des quetes
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 23:45:40 +02:00
koka e126ec9eea fix(ambiance): stabilize white disc playback
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 21:36:04 +02:00
koka 13a74ddbd2 Merge pull request 'release: Sanctuary 26.2.0-alpha.214 — disque blanc solo' (#85) from release/26.2.0-alpha.214 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #85
2026-08-29 18:44:13 +00:00
koka b221ca1b52 release: prepare 26.2.0-alpha.214
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:40:54 +02:00
koka 05505279f8 Merge pull request 'fix(ambiance): attendre la synchronisation du disque blanc' (#84) from fix/white-disc-solo-sync into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #84
2026-08-29 18:35:33 +00:00
koka 61e66d3a11 fix(ambiance): attendre la synchronisation du disque blanc
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:30:41 +02:00
koka 3aa36ed129 Merge pull request 'release: Sanctuary 26.2.0-alpha.213 — correctif Ambiance' (#83) from release/26.2.0-alpha.213 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #83
2026-08-29 18:03:43 +00:00
koka 90b9eee27e release: prepare 26.2.0-alpha.213
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 20:02:22 +02:00
koka 7d800d8932 Merge pull request 'fix(ambiance): corriger le crash des événements de niveau' (#82) from fix/ambiance-level-event-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #82
2026-08-29 17:57:58 +00:00
koka 913a754f20 fix(ambiance): corriger le crash des événements de niveau
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:54:46 +02:00
koka 73d32999e5 Merge pull request 'release: Sanctuary 26.2.0-alpha.212 — correctif de démarrage' (#81) from release/26.2.0-alpha.212 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #81
2026-08-29 17:43:40 +00:00
koka 86034ca71f release: prepare 26.2.0-alpha.212
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:41:04 +02:00
koka db91698c09 Merge pull request 'fix(sanctuary): corriger le crash du clic molette' (#80) from fix/black-market-middle-click-crash into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #80
2026-08-29 17:35:20 +00:00
koka 7f74114d31 fix(sanctuary): corriger le crash du clic molette
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 19:32:54 +02:00
koka 53db634ec2 Merge pull request 'release: Sanctuary 26.2.0-alpha.211 — Abjection et correctifs' (#79) from release/26.2.0-alpha.211 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #79
2026-08-29 17:04:04 +00:00
koka 400f1f6289 release: prepare 26.2.0-alpha.211
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 18:57:52 +02:00
koka 596432f3f7 Merge pull request 'feat(onlyfun): ajouter la touche Abjection' (#78) from feature/abjection-keybind into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #78
2026-08-29 16:51:17 +00:00
250 changed files with 13313 additions and 476 deletions
+184 -2
View File
@@ -6,9 +6,190 @@ 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é
## Non publié
Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`**, **Ambiance `0.0.0-alpha.15`**, **Only Fun `0.0.0-alpha.24`** et **Ouch `0.0.0-alpha.10`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
Modules modifiés : **Sanctuary `0.0.0-alpha.131`** et **Another World `0.0.0-alpha.57`**.
- ajoute la dimension persistante `sanctuary:backroom`, accessible en saccroupissant devant un lit et quittée uniquement par un lit, avec arrivées variées dans une zone architecturale de 2 000 blocs ;
- transfère atomiquement vers la Mailbox tout l’équipement dentrée, y compris les rangées étendues, l’œuf compagnon et la cape, avec reprise idempotente après interruption ;
- conserve les objets perdus au dépop, dans le vide et lors des morts en Backroom dans un registre borné et non duplicable ;
- transforme tous les coffres de la dimension en butin renouvelable, pauvre la plupart du temps, qui privilégie les pertes personnelles dans les premiers coffres avant les pertes anciennes partagées ;
- génère des villes, bureaux, plaines, agrégats flottants et pièces visuellement retournées à partir de palettes composées, en réservant les matériaux dAnother World aux anomalies et les blocs précieux à de rares cachettes ;
- fait enregistrer la mousse turquoise par Another World via lAPI publique de matériaux Backroom, sans dépendance circulaire ;
- migre Sanctuary vers les données v14 sans changer le protocole réseau 24, sans réinitialiser les sauvegardes ni effacer les constructions.
## `26.2.0-alpha.226`
Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225.
- ajoute `sanctuary:white_cape`, une cape blanche craftable avec six laines blanches disposées comme une porte, en deux colonnes de trois ;
- permet d'associer simplement la cape et n'importe quelle bannière dans l'établi afin d'en conserver la couleur de fond et jusqu'à six motifs ;
- affiche les motifs dans l'inventaire et sur la cape équipée, avec la physique native des capes, y compris en multijoueur ;
- conserve les sauvegardes Sanctuary en data v13 ; le payload d'apparence transporte désormais la couleur et les motifs bornés, donc le protocole Sanctuary passe de 23 à 24 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.225`
Modules modifiés : **Another World `0.0.0-alpha.56`**, **It's Alive `0.0.0-alpha.38`**, **I Like To Move It `0.0.0-alpha.18`**, **Only Fun `0.0.0-alpha.27`** et **Sanctuary `0.0.0-alpha.129`**. Les cinq autres modules conservent leur version issue de l'alpha.224.
- exige que lattaquant possède son propre coffre-fort actif avant de commencer un braquage, revalide cette condition pendant le forage et refuse défensivement tout transfert si le coffre disparaît entre-temps ;
- empêche les familiers temporaires Sanctuary de produire du butin vanilla, de lexpérience ou une lootbox Only Fun, et les exclut des contrats de panneau comme des demandes communautaires ;
- conserve la variante Canaplia des seize graines historiques pendant le placement, la croissance naturelle, la poudre dos, la création de la partie haute, le rechargement et la récolte, ce qui réactive notamment lhybridation Tendre + Olive vers Dorée ;
- remplace la laine violette du Magic Carpet par la texture historique 16 × 16 du Totem de Notch, utilisée par lobjet canonique et son alias hérité sans modifier la plateforme 3 × 3 ;
- conserve tous les IDs, sauvegardes et formats de données ; les protocoles réseau restent Another World 2, It's Alive 4, I Like To Move It 5, Only Fun 3 et Sanctuary 23, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.224`
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.128`** et **MasterKey `0.0.0-alpha.9`**.
- étend le clic molette aux coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, tout en excluant les fours, le Shop et le terminal Red-Stoner ;
- trie l'inventaire du joueur lorsque le curseur survole l'une de ses cases dans un conteneur, sans toucher à la hotbar, et replace la hotbar en bas après connexion ou achat d'une rangée ;
- 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 une récompense physique unique ;
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13 ; le payload de tri ayant gagné un booléen borné, le protocole Sanctuary passe de 22 à 23 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
## `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.
- ajoute un tri natif de linventaire par clic molette configurable, uniquement sur les rangées de stockage débloquées ;
- fusionne et range les piles depuis le haut sans déplacer la hotbar, larmure, la main secondaire, le craft ni les emplacements spéciaux ;
- garde le tri sous autorité serveur, avec un délai de huit ticks, et lignore en créatif ou pendant la saisie dans un champ texte ;
- ajoute un style configurable au suivi des quêtes (`N` par défaut), entre le texte transparent et le panneau original de Chris ;
- place automatiquement le style texte sous la hauteur réelle de la colonne gauche du F3 permanent afin que les deux affichages ne se recouvrent pas ;
- nomme et ordonne les contrats suivis par niveau de récompense, rubis puis saphir puis émeraude/autres, au lieu du libellé générique « Contrat du jour » ;
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.198` et synchronise ses empreintes entre Packwiz et Prism ;
- conserve les données Sanctuary v11, le protocole réseau 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation nest nécessaire.
## `26.2.0-alpha.222`
Modules modifiés : **Red-Stoner `0.0.0-alpha.13`** et **Sanctuary `0.0.0-alpha.124`**. Les huit autres modules conservent leur version issue de l'alpha.221.
- ajoute au terminal de stockage une recherche clavier focalisée à l'ouverture, compatible avec les noms affichés, les IDs, les traductions, les accents et Unicode ;
- affiche les grandes quantités du terminal sous forme compacte (`1K`, `1.2K`, `12K`, `1.2M`) tout en conservant la valeur exacte dans l'infobulle ;
- stabilise les emplacements du terminal lorsqu'une pile est vidée afin que les ressources suivantes ne glissent plus sous le curseur, puis recompacte volontairement lors d'une nouvelle recherche ou à la réouverture ;
- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ;
- affiche chaque quête active comme un simple texte ombré en haut à gauche, sans panneau, bordure, titre, message vide ni barre de progression ;
- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ;
- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches.
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.196` et synchronise Packwiz avec l'index Prism/Modrinth ;
- conserve Red-Stoner data v3/protocole 7, Sanctuary data v11/protocole 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.221`
Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220.
- corrige la cause réelle du disque blanc ralenti et trop grave : la piste AAC YouTube reste décodée en stéréo native à 48 kHz, puis chaque paire gauche/droite est explicitement moyennée en un échantillon mono positionnel ;
- supprime la compensation OpenAL approximative à `4/3` et restitue la musique à sa hauteur et sa vitesse originales avec une hauteur `1.0` ;
- conserve un flux PCM mono signé 16 bits little-endian pour l'audio positionnel du jukebox, sans silence ajouté ni changement de durée ;
- ajoute un test déterministe du downmix stéréo et valide le lien signalé sur quatre secondes : 4 000 ms de source donnent 4 000 ms de PCM à 48 kHz ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.220`
Module modifié : **Red-Stoner `0.0.0-alpha.12`**. Les neuf autres modules conservent leur version issue de l'alpha.219.
- remplace l’écran propriétaire du terminal par un véritable menu de conteneur inspiré des coffres vanilla et composé avec le panneau dinventaire Sanctuary ;
- supprime le bouton de fermeture dédié : Échap et la touche dinventaire suivent désormais le comportement normal du jeu ;
- affiche et synchronise toutes les rangées dinventaire débloquées par Sanctuary, dune à six, y compris les deux rangées persistantes supplémentaires ;
- permet le retrait par clic gauche, le retrait unitaire par clic droit et le retrait vers linventaire par shift-clic ; un clic avec une pile portée ou un shift-clic depuis linventaire dépose dans le réseau ;
- conserve les 54 cases comme une vue agrégée non réorganisable et affiche les quantités exactes sans créer de stockage virtuel ;
- dépose dans le premier coffre ou baril disponible selon lordre déterministe de découverte, remplit dabord ses piles compatibles puis ses cases vides et ne passe au stockage suivant quen cas de besoin ;
- supprime les anciens payloads spécifiques au terminal au profit de la synchronisation native des menus, tout en gardant la distance, la topologie et les transferts sous autorité serveur ;
- conserve les données Red-Stoner v3 et les sauvegardes existantes ; le protocole réseau Red-Stoner passe de 6 à 7, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.219`
Modules modifiés : **Sanctuary `0.0.0-alpha.122`**, **I Like To Move It `0.0.0-alpha.16`** et **Ambiance `0.0.0-alpha.21`**. Les sept autres modules conservent leur version issue de l'alpha.218.
- ajoute `sanctuary:alpha`, une île Minecraft Indev finie de 128 × 128 × 64 blocs, déterministe par seed, avec plages, cavernes, minerais, eau, lave, végétation et maison de départ historique ;
- borne le monde par un fond en bedrock et un anneau de barrières, sans placement au-delà de X/Z 64…63, et expose les positions stables de la résidence et de l'arrivée sûre via l'API publique de Sanctuary ;
- ajoute la dimension, son biome, son type et ses réglages aux profils Sanctuary et Overworld, tout en conservant la migration `portals:alpha` vers `sanctuary:alpha` ;
- active la route de portail v2 en cobblestone moussue, cadre 4 × 5 et intérieur 2 × 3, avec validation serveur, refus si la dimension manque, paire persistante, déliaison à la casse et arrivée distante hors de la maison ;
- compose localement les packs officiels Golden Days Base puis Alpha uniquement dans `sanctuary:alpha`, restaure exactement la pile précédente à la sortie ou déconnexion et laisse les textures vanilla fonctionner si les ZIP manquent ;
- ne distribue aucun asset Golden Days dans les JAR, Packwiz ou `.mrpack`, documente les liens et la licence officiels, et n'impose aucun fichier au serveur dédié ;
- ajoute les traductions FR/EN/RU et des tests déterministes pour le générateur, le portail et la composition des packs ;
- conserve Sanctuary data v11/protocole 22, I Like To Move It data v3/protocole 5 et Ambiance data v2/protocole 6, sans réinitialisation de sauvegarde.
## `26.2.0-alpha.218`
Modules modifiés : **Red-Stoner `0.0.0-alpha.11`**, **Only Fun `0.0.0-alpha.25`** et **Sanctuary `0.0.0-alpha.121`**. Les sept autres modules conservent leur version issue de l'alpha.217.
- ajoute `redstoner:storage_terminal`, une demi-dalle verticale de huit pixels qui se pose uniquement sur la face horizontale dun coffre, coffre piégé ou baril ;
- découvre sans charger de chunk les conteneurs adjacents par face, verticalement compris, et refuse clairement tout réseau qui dépasse 128 blocs physiques ;
- agrège uniquement les piles dont litem et tous les composants sont strictement identiques, puis les affiche sur des pages défilantes de 54 entrées ;
- valide côté serveur la distance, le terminal, la topologie, la page et lidentité de chaque pile avant un retrait par pile, un retrait unitaire ou un dépôt par shift-clic ;
- fabrique le terminal avec quatre plaques dargent, trois verres teintés, un Controller et un lingot de titane, sans entrée Shop, Black Market ni récompense de lootbox ;
- ajoute les traductions FR/EN/RU, les ressources bloc/item/loot/recette et des tests déterministes de topologie, pagination, agrégation et transferts ;
- conserve les données Red-Stoner v3, Only Fun v2 et Sanctuary v11 ; seul le protocole réseau Red-Stoner passe de 5 à 6, sans nouveau monde ni réinitialisation des sauvegardes.
## `26.2.0-alpha.217`
Module modifié : **Ambiance `0.0.0-alpha.20`**. Les neuf autres modules conservent leur version issue de l'alpha.216.
- corrige la hauteur trop grave et la vitesse trop lente du disque blanc en compensant la cadence du flux AAC YouTube par un facteur OpenAL exact de `4/3` ;
- réapplique cette hauteur pendant toute la lecture afin quaucune mise à jour du canal audio ne la ramène à la valeur ralentie ;
- valide la correction sur le lien signalé en comparant vingt secondes décodées par Lavaplayer au flux AAC direct, puis conserve les tests PCM 48 kHz et de niveau sonore ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.20` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.216`
Module modifié : **Ambiance `0.0.0-alpha.19`**. Les neuf autres modules conservent leur version issue de l'alpha.215.
- corrige le disque blanc ralenti et dégradé en remplissant continûment les tampons PCM dune seconde demandés par Minecraft au lieu de les compléter prématurément avec du silence ;
- conserve une fréquence réelle de 48 kHz, un flux mono 16 bits little-endian et force la vitesse OpenAL normale à `1.0` ;
- borne chaque attente de trame à 250 ms après le démarrage afin de garder un flux continu sans bloquer indéfiniment le thread audio ;
- ajoute un test déterministe des tampons Minecraft et renforce le test YouTube réel sur quatre secondes complètes ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.19` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.215`
Module modifié : **Ambiance `0.0.0-alpha.18`**. Les neuf autres modules conservent leur version issue de l'alpha.214.
- corrige le crash natif déclenché par linsertion ou larrêt du disque blanc en laissant Minecraft libérer le canal OpenAL sur son thread audio ;
- rétablit le décodage audible du lien YouTube signalé grâce au profil iOS corrigé et à une révision épinglée de `youtube-source` ;
- borne uniquement lattente initiale du flux puis poursuit la lecture sans blocage, avec silence de compensation lorsque les données arrivent en retard ;
- transforme les erreurs et blocages de lecture en arrêt propre accompagné dun message au joueur ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
- distribue Ambiance `0.0.0-alpha.18` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.214`
Module modifié : **Ambiance `0.0.0-alpha.17`**. Les neuf autres modules conservent leur version issue de l'alpha.213.
- attend de façon bornée la synchronisation client du jukebox avant de charger un disque blanc, ce qui rétablit sa lecture en solo ;
- affiche immédiatement le chargement puis un échec explicite si le jukebox n'est toujours pas synchronisé après cinq secondes ;
- accepte toujours les liens de partage YouTube avec paramètres, notamment `https://youtu.be/Ue5ZBe-GzSM?si=…` ;
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
- distribue Ambiance `0.0.0-alpha.17` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.213`
Module modifié : **Ambiance `0.0.0-alpha.16`**. Les neuf autres modules conservent leur version issue de l'alpha.212.
- corrige le crash client au démarrage d'Ambiance sous Minecraft 26.2 en ciblant le nouveau gestionnaire vanilla `LevelEventHandler` pour l'arrêt du disque blanc ;
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
- remplace le JAR Ambiance cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.212`
Module modifié : **Sanctuary `0.0.0-alpha.120`**. Les neuf autres modules conservent leur version issue de l'alpha.211.
- corrige le crash client au démarrage provoqué par le mixin du clic molette du Black Market sous Minecraft 26.2 ;
- cible désormais la méthode vanilla `pickBlockOrEntity`, sans modifier les données Sanctuary v11 ni le protocole réseau 22.
- remplace le JAR Sanctuary cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.211`
Modules modifiés : **Ambiance `0.0.0-alpha.15`**, **It's Alive `0.0.0-alpha.36`**, **Only Fun `0.0.0-alpha.24`**, **Ouch `0.0.0-alpha.10`** et **Sanctuary `0.0.0-alpha.119`**. Les cinq autres modules conservent leur version issue de lalpha.210.
- remplace les pages noires du Livre interdit par laffichage clair du livre vanilla ;
- conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4.
@@ -26,6 +207,7 @@ Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.11
- ajoute la touche configurable « Chier » dans les contrôles Only Fun, assignée à `J` par défaut et sans répétition lorsque la touche reste maintenue ;
- transmet une demande C2S vide, déduplique le spam dun même tick et revalide côté serveur la connexion, la vie et le mode spectateur avant de réutiliser exactement laction `/shit` ;
- conserve les données Only Fun v2 et porte son protocole réseau de 2 à 3, sans nouveau cooldown, besoin ni donnée persistante.
- distribue les cinq JAR mis à jour dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
## `26.2.0-alpha.210`
+23 -3
View File
@@ -12,7 +12,7 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
@@ -22,11 +22,29 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
Version courante du pack distribué : **`26.2.0-alpha.210`**. Cette release distribue le Livre interdit Canaplia avec It's Alive **`0.0.0-alpha.35`**, ainsi que Sanctuary **`0.0.0-alpha.118`**, Only Fun **`0.0.0-alpha.21`** et I Like To Move It **`0.0.0-alpha.15`**.
Version courante du pack distribué : **`26.2.0-alpha.226`**. Cette release distribue Sanctuary **`0.0.0-alpha.130`** ; les neuf autres modules conservent leur version issue de l'alpha.225.
Lalpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, dexpérience, de lootbox ni de progression de quête, les seize variantes Canaplia survivent à tout leur cycle de culture, et le Magic Carpet retrouve la texture historique du Totem de Notch à la place de la laine violette. Les IDs, sauvegardes, formats de données et protocoles restent compatibles.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
Lalpha.210 ajoute le Livre interdit, accélère le Happy Ghast piloté, regroupe les corrections du Shop, des requêtes, des capes et des noms de Zombies, puis actualise JEI en **`30.28.0.193`**. Elle met à jour les packs Modrinth, Prism et Packwiz sans exiger de nouveau monde ni réinitialiser les sauvegardes.
Lalpha.224 ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox. Le clic molette trie aussi les coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, ou l'inventaire du joueur lorsqu'il est survolé, sans déplacer la hotbar. Le protocole Sanctuary passe à 23 : client et serveur doivent utiliser l'alpha.224.
Lalpha.223 ajoute le tri natif des rangées de stockage par clic molette sans déplacer la hotbar ni les emplacements spéciaux. Le suivi des quêtes propose désormais le texte transparent aligné sous la colonne gauche du F3 ou le panneau original de Chris, avec bascule configurable sur `N` et contrats ordonnés par récompense. JEI passe à la bêta Fabric 26.2 `30.29.0.198`.
Lalpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions dIDs ditems et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
Lalpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
Lalpha.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 dinventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans lordre de découverte avant de passer au suivant.
Lalpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
Lalpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants ditems restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
Lalpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par lalpha.221, qui corrige directement la conversion stéréo vers mono.
Lalpha.216 corrige la lecture lente et dégradée du disque blanc : les quatre tampons PCM initiaux de Minecraft sont maintenant remplis continûment à 48 kHz, avec une vitesse OpenAL normale, au lieu d’être majoritairement complétés par du silence. Les packs Modrinth, Prism et Packwiz sont mis à jour sans exiger de nouveau monde ni réinitialiser les sauvegardes.
Lalpha.207 rend le stockage du coffre-fort pratiquement illimité : rubis, saphirs et émeraudes utilisent chacun un solde 64 bits. Les 27 anciennes piles sont additionnées automatiquement lors du chargement, le Shop continue de consommer directement ces soldes et les 20 % pillés sont crédités dans le coffre-fort de lattaquant sans créer des millions dentités ou de piles dobjets.
@@ -98,6 +116,8 @@ Lalpha.173 centralise laudio du Miner : une seule boucle de frappe sur le
Lalpha.171 rassemble les **46 capes** dans un onglet créatif dédié « Capes ». Elles ne sont plus mélangées à longlet vanilla Tools & Utilities et ne sont donc affichées quune seule fois.
Lalpha.226 ajoute une 47e cape, `sanctuary:white_cape`, distincte de la collection Only Fun. Elle se fabrique avec six laines blanches en deux colonnes de trois, puis accepte une bannière dans une recette simple pour en conserver la couleur et jusqu’à six motifs ; les motifs saffichent dans linventaire comme une fois la cape équipée.
Lalpha.170 ajoute les pictogrammes fournis aux emplacements vides de la cape et du familier. Ils disparaissent dès quune cape ou un œuf est équipé, sans modifier les objets, la persistance ni le protocole.
Lalpha.169 agrandit la collection de 13 à **46 capes équipables**. Les 33 nouvelles textures 64×32 fournies sont conservées telles quelles, avec une icône dinventaire, un modèle et des traductions FR/EN/RU pour chaque cape. Elles utilisent le slot, la persistance et le rendu multijoueur déjà existants, restent sans recette et rejoignent toutes la lootbox de capes Only Fun. Les caméras Canon restent accessibles dans la lootbox normale et peuvent désormais aussi apparaître directement, très rarement, dans les coffres dexploration et du donjon de cobblestone.
+36
View File
@@ -0,0 +1,36 @@
# Textures historiques Golden Days dans Alpha
Ambiance peut charger les packs officiels Golden Days uniquement pendant que
le client se trouve dans `sanctuary:alpha`. Aucun fichier, texture ou dérivé de
Golden Days n'est distribué dans les JAR ou dans le `.mrpack` Sanctuary.
## Installation cliente facultative
1. Télécharger [Golden Days Base](https://www.curseforge.com/minecraft/texture-packs/golden-days)
depuis sa page officielle.
2. Télécharger [Golden Days Alpha](https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha)
depuis sa page officielle.
3. Placer les deux ZIP sans les extraire dans le dossier `resourcepacks/` du
client. Les noms doivent conserver les marqueurs `golden-days-base` et
`golden-days-alpha` ; les espaces et `_` sont aussi reconnus.
4. Laisser les deux packs désactivés dans l'écran vanilla. Ambiance les compose
temporairement dans l'ordre **Base**, puis **Alpha**.
À l'entrée dans Alpha, Ambiance capture la pile sélectionnée exacte, ajoute les
deux packs en mémoire et recharge les ressources. À la sortie ou à la
déconnexion, la pile capturée est restaurée exactement. Le fichier d'options
vanilla n'est jamais modifié : après une interruption ou un crash pendant un
rechargement, le prochain démarrage retrouve donc la pile enregistrée.
Si un des deux packs manque ou si un rechargement échoue, le client reste
jouable avec les textures vanilla et affiche un message lisible. Un serveur
dédié n'a besoin d'aucun ZIP.
## Licence et redistribution
Golden Days est publié sous **All Rights Reserved**. Sa
[licence officielle](https://poeticrainbow.github.io/golden-days/license/)
interdit notamment de redistribuer ou de reconditionner les ressources sans
autorisation. Sanctuary ne fournit que l'intégration locale ci-dessus et les
liens officiels. Les archives de développement restent hors du dépôt et de
tous les artefacts distribués.
+13
View File
@@ -0,0 +1,13 @@
# Third-party notices
## Golden Days Base and Golden Days Alpha
Optional client resource packs by PoeticRainbow. No Golden Days asset is
included in Ambiance or Sanctuary.
- Base project: https://www.curseforge.com/minecraft/texture-packs/golden-days
- Alpha project: https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha
- License: https://poeticrainbow.github.io/golden-days/license/
Golden Days is All Rights Reserved. Players obtain the two archives separately
from the official project pages for local use.
+13 -7
View File
@@ -16,16 +16,22 @@ jukebox vanilla.
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
trente minutes, et limite les lectures simultanées à quatre ;
- la lecture utilise le canal `RECORDS`, un flux PCM mono positionnel et une atténuation à
64 blocs. Elle sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se ferme.
- la source AAC stéréo est décodée à 48 kHz (48 000 Hz) puis convertie explicitement en PCM mono 16 bits
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
vitesse et la hauteur originales tout en restant positionnelle ;
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
64 blocs. La lecture sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se
ferme.
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
## Dépendances clientes embarquées
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et youtube-source `1.18.2` (MIT), ainsi que
leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans
les archives dorigine. Laccès YouTube est effectué uniquement par le client et peut cesser de
fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire nest
acceptée par le serveur.
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et sur le commit youtube-source
`f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3` (MIT), épinglé pour intégrer les correctifs de lecture
YouTube publiés après `1.18.2`. Le profil iOS corrigé est utilisé sans compte, OAuth, jeton privé ou
service de chiffrement tiers. Les dépendances sont placées en JAR imbriqués par Loom et leurs avis
de licence restent dans les archives dorigine. Laccès YouTube est effectué uniquement par le
client et peut cesser de fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou
URL arbitraire nest acceptée par le serveur.
+116 -8
View File
@@ -1,5 +1,7 @@
import groovy.json.JsonSlurper
def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"
configurations {
whiteDiscLibraries
implementation.extendsFrom whiteDiscLibraries
@@ -13,14 +15,14 @@ dependencies {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
whiteDiscLibraries("dev.lavalink.youtube:v2:1.18.2") {
whiteDiscLibraries("dev.lavalink.youtube:v2:${youtubeSourceVersion}") {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
[
"dev.arbjerg:lavaplayer-natives:2.2.7",
"dev.arbjerg:lava-common:2.2.7",
"dev.lavalink.youtube:common:1.18.2",
"dev.lavalink.youtube:common:${youtubeSourceVersion}",
"org.mozilla:rhino-engine:1.7.15",
"org.mozilla:rhino:1.7.15",
"commons-io:commons-io:2.13.0",
@@ -40,6 +42,12 @@ dependencies {
}
}
tasks.named("jar") {
from("THIRD_PARTY_NOTICES.md") {
rename { "THIRD_PARTY_NOTICES_ambiance.md" }
}
}
tasks.register("ambianceVisualApiSmoke", JavaExec) {
group = "verification"
description = "Checks bounded visual-effect requests exposed to gameplay modules."
@@ -64,15 +72,41 @@ tasks.register("whiteDiscUrlSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke"
}
tasks.register("whiteDiscPlaybackSmoke", JavaExec) {
group = "verification"
description = "Manually checks live YouTube loading and PCM decoding for the white disc."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscPlaybackSmoke"
}
tasks.register("whiteDiscStreamSmoke", JavaExec) {
group = "verification"
description = "Checks continuous 48 kHz PCM buffering for Minecraft's white-disc channel."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.LavaplayerAudioStreamSmoke"
}
tasks.register("alphaTexturePackManagerSmoke", JavaExec) {
group = "verification"
description = "Checks local Golden Days detection, ordering and exact stack preservation."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.AlphaTexturePackManagerSmoke"
}
tasks.named("test") {
failOnNoDiscoveredTests = false
}
tasks.register("verifyAmbiance") {
group = "verification"
description = "Checks Ambiance alpha.15, including the bounded YouTube white-disc playback contract."
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
inputs.files(fileTree("src/main/java"))
inputs.files(fileTree("src/main/resources"))
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java"))
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStreamSmoke.java"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.127-daily-ambiance-sodium-iris-to-alpha.128.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.128-ambiance-visuals-canon-iris-to-alpha.129.json"))
@@ -120,8 +154,14 @@ tasks.register("verifyAmbiance") {
file("src/main/resources/data/ambiance/recipe/white_disc.json"))
def whiteDiscSong = new JsonSlurper().parse(
file("src/main/resources/data/ambiance/jukebox_song/white_disc.json"))
def levelEventMixin = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelEventHandlerMixin.java").text
def whiteDiscAudioPlayer = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java").text
def whiteDiscPlaybackSmoke = file(
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
if (project.version.toString() != "0.0.0-alpha.15"
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.source?.pack_version != "26.2.0-alpha.125"
|| migration.target?.pack_version != "26.2.0-alpha.126"
|| migration.target?.modules?.ambiance != "0.0.0-alpha.1"
@@ -319,10 +359,29 @@ tasks.register("verifyAmbiance") {
|| !javaText.contains('PlayerLookup.tracking(level, pos)')
|| !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)')
|| !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4')
|| !javaText.contains('REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS')
|| !javaText.contains('PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()')
|| !javaText.contains('MAXIMUM_START_WAIT_TICKS = 100')
|| !javaText.contains('tickPendingStarts(client)')
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|| !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L')
|| !javaText.contains('new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE')
|| !javaText.contains('new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|| !javaText.contains('initialWaitAttempted')
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|| !javaText.contains('SOURCE_CHANNELS = 2')
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|| whiteDiscAudioPlayer.contains('.release()')
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
|| !whiteDiscPlaybackSmoke.contains('PCM cadence: 100%% speed')
|| !whiteDiscPlaybackSmoke.contains('stream.decodedTimelineMillis() != 4_000L')
|| !whiteDiscPlaybackSmoke.contains('stream.paddedSilenceBytes() != 0L')
|| !javaText.contains('SoundSource.RECORDS')
|| !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)')
|| !javaText.contains('LONG_HOSTS = Set.of(')
@@ -334,6 +393,9 @@ tasks.register("verifyAmbiance") {
|| whiteDiscRecipe.key?.W != "minecraft:white_dye"
|| whiteDiscSong.sound_event != "minecraft:intentionally_empty"
|| whiteDiscSong.length_in_seconds != 1800
|| !levelEventMixin.contains('@Mixin(LevelEventHandler.class)')
|| !levelEventMixin.contains('@Inject(method = "levelEvent"')
|| !levelEventMixin.contains('ambiance$stopWhiteDisc(int type, BlockPos pos, int data,')
|| ambianceLanguages.any { locale, translations ->
!["item.ambiance.white_disc", "tooltip.ambiance.white_disc",
"message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every {
@@ -341,8 +403,10 @@ tasks.register("verifyAmbiance") {
}
}
|| !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7')
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:1.18.2')) {
throw new GradleException("Ambiance alpha.15 white-disc playback contract is incomplete")
|| !file("build.gradle").text.contains(
'def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"')
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:${youtubeSourceVersion}')) {
throw new GradleException("Ambiance white-disc playback contract is incomplete")
}
[
@@ -391,9 +455,53 @@ tasks.register("verifyAmbiance") {
}
}
tasks.register("verifyAlphaTextures") {
group = "verification"
description = "Checks the alpha.21 local-only Golden Days overlay and exact restore contract."
inputs.file(file("GOLDEN_DAYS.md"))
inputs.file(file("THIRD_PARTY_NOTICES.md"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java"))
inputs.files(fileTree("src/main/resources/assets/ambiance/lang") { include "*.json" })
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
doLast {
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
def manager = file(
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java").text
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${it}.json"))
}
def forbiddenArchives = rootProject.fileTree(rootProject.projectDir) {
include "**/*golden*days*.zip", "**/*golden*days*.jar"
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
}.files
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|| migration.alpha_textures?.client_only != true
|| migration.alpha_textures?.bundled_assets != false
|| migration.alpha_textures?.overlay_order != ["base", "alpha"]
|| migration.alpha_textures?.previous_selection_restored_exactly != true
|| migration.alpha_textures?.saved_options_modified != false
|| migration.alpha_textures?.dedicated_server_files_required != false
|| !manager.contains("SanctuaryDimensions.ALPHA")
|| !manager.contains("previousSelection = List.copyOf")
|| !manager.contains("selectionWithOverlay(previousSelection")
|| manager.contains("updateResourcePacks")
|| locales.any { !it.containsKey("message.ambiance.alpha_textures_missing")
|| !it.containsKey("message.ambiance.alpha_textures_restore_failed") }
|| !forbiddenArchives.isEmpty()
|| !file("THIRD_PARTY_NOTICES.md").text.contains("No Golden Days asset")) {
throw new GradleException("Ambiance alpha.21 Golden Days overlay contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyAmbiance")
dependsOn tasks.named("ambianceScheduleSmoke")
dependsOn tasks.named("ambianceVisualApiSmoke")
dependsOn tasks.named("whiteDiscUrlSmoke")
dependsOn tasks.named("whiteDiscStreamSmoke")
dependsOn tasks.named("alphaTexturePackManagerSmoke")
dependsOn tasks.named("verifyAlphaTextures")
}
@@ -0,0 +1,192 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import net.minecraft.server.packs.repository.PackRepository;
/**
* Applies locally installed Golden Days packs only while the player is in
* {@code sanctuary:alpha}. The selected stack is changed in memory only; the
* player's saved resource-pack options remain untouched.
*/
public final class AlphaTexturePackManager {
static final String BASE_MARKER = "golden-days-base";
static final String ALPHA_MARKER = "golden-days-alpha";
private static final int MAX_RESTORE_RETRIES = 1;
private static boolean requestKnown;
private static boolean alphaRequested;
private static boolean reloadInProgress;
private static boolean failedForCurrentRequest;
private static boolean missingMessageShown;
private static boolean reconcilePending = true;
private static int restoreFailures;
private static List<String> previousSelection = List.of();
private static boolean previousSelectionCaptured;
private AlphaTexturePackManager() {
}
public static void register() {
ClientTickEvents.END_CLIENT_TICK.register(AlphaTexturePackManager::tick);
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> request(client, false));
}
private static void tick(Minecraft client) {
boolean shouldUseAlphaTextures = client.level != null
&& client.level.dimension().equals(SanctuaryDimensions.ALPHA);
request(client, shouldUseAlphaTextures);
if (!reloadInProgress && reconcilePending) {
reconcilePending = false;
reconcile(client);
}
}
private static void request(Minecraft client, boolean requested) {
if (requestKnown && requested == alphaRequested) return;
alphaRequested = requested;
requestKnown = true;
failedForCurrentRequest = false;
missingMessageShown = false;
restoreFailures = 0;
if (requested && !previousSelectionCaptured) {
PackRepository repository = client.getResourcePackRepository();
repository.reload();
previousSelection = List.copyOf(repository.getSelectedIds());
previousSelectionCaptured = true;
}
reconcilePending = true;
}
private static void reconcile(Minecraft client) {
PackRepository repository = client.getResourcePackRepository();
repository.reload();
List<String> selected = List.copyOf(repository.getSelectedIds());
if (alphaRequested && !failedForCurrentRequest) {
Optional<PackPair> packs = findPacks(repository.getAvailableIds());
if (packs.isEmpty()) {
failedForCurrentRequest = true;
showMissingPackMessage(client);
applyIfDifferent(client, selected, previousSelection, false);
return;
}
List<String> desired = selectionWithOverlay(previousSelection, packs.orElseThrow());
applyIfDifferent(client, selected, desired, true);
return;
}
if (!previousSelectionCaptured) return;
applyIfDifferent(client, selected, previousSelection, false);
}
private static void applyIfDifferent(Minecraft client, List<String> selected,
List<String> desired, boolean enablingAlpha) {
if (selected.equals(desired)) {
if (!enablingAlpha && !alphaRequested) clearSnapshot();
return;
}
client.getResourcePackRepository().setSelected(desired);
reloadInProgress = true;
AmbianceMod.LOGGER.info("[{}] Reloading client resources for Alpha texture mode: {}.",
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enabled" : "disabled");
client.reloadResourcePacks().whenComplete((ignored, error) -> client.execute(() -> {
reloadInProgress = false;
if (error != null) {
AmbianceMod.LOGGER.error("[{}] Could not {} the Golden Days Alpha overlay.",
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enable" : "restore", error);
if (enablingAlpha) {
failedForCurrentRequest = true;
reconcilePending = true;
} else if (++restoreFailures <= MAX_RESTORE_RETRIES) {
reconcilePending = true;
} else {
client.getResourcePackRepository().setSelected(previousSelection);
showRestoreFailureMessage(client);
}
return;
}
if (!enablingAlpha && !alphaRequested) clearSnapshot();
if (enablingAlpha != alphaRequested) reconcilePending = true;
}));
}
static Optional<PackPair> findPacks(Collection<String> availableIds) {
Optional<String> base = findPack(availableIds, BASE_MARKER);
Optional<String> alpha = findPack(availableIds, ALPHA_MARKER);
return base.isPresent() && alpha.isPresent()
? Optional.of(new PackPair(base.orElseThrow(), alpha.orElseThrow()))
: Optional.empty();
}
static List<String> selectionWithOverlay(Collection<String> previous, PackPair packs) {
List<String> desired = selectedWithoutGoldenDays(previous);
// Pack order is bottom-to-top: Base first, then its Alpha overlay.
desired.add(packs.base());
desired.add(packs.alpha());
return List.copyOf(desired);
}
private static List<String> selectedWithoutGoldenDays(Collection<String> selectedIds) {
List<String> result = new ArrayList<>(selectedIds.size());
for (String id : selectedIds) {
if (!isGoldenDaysPack(id)) result.add(id);
}
return result;
}
private static Optional<String> findPack(Collection<String> availableIds, String marker) {
return availableIds.stream()
.filter(id -> normalized(id).contains(marker))
.sorted(Comparator.naturalOrder())
.findFirst();
}
static boolean isGoldenDaysPack(String id) {
String normalized = normalized(id);
return normalized.contains(BASE_MARKER) || normalized.contains(ALPHA_MARKER);
}
private static String normalized(String id) {
return id.toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-');
}
private static void clearSnapshot() {
previousSelection = List.of();
previousSelectionCaptured = false;
restoreFailures = 0;
}
private static void showMissingPackMessage(Minecraft client) {
if (!missingMessageShown && client.player != null) {
client.gui.hud.getChat().addClientSystemMessage(
Component.translatable("message.ambiance.alpha_textures_missing"));
missingMessageShown = true;
}
}
private static void showRestoreFailureMessage(Minecraft client) {
if (client.player != null) {
client.gui.hud.getChat().addClientSystemMessage(
Component.translatable("message.ambiance.alpha_textures_restore_failed"));
}
}
record PackPair(String base, String alpha) {
PackPair {
if (base == null || alpha == null || base.equals(alpha)) {
throw new IllegalArgumentException("Golden Days Base and Alpha packs must be distinct");
}
}
}
}
@@ -33,6 +33,7 @@ public final class AmbianceClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
RealtimeClient.initialize();
AlphaTexturePackManager.register();
CelestialSkyRenderer.register();
ClientTickEvents.END_CLIENT_TICK.register(DynamicTorchLights::tick);
ClientPlayNetworking.registerGlobalReceiver(AmbianceEnvironmentPayload.TYPE, (payload, context) ->
@@ -9,16 +9,26 @@ import java.util.concurrent.TimeoutException;
import javax.sound.sampled.AudioFormat;
import net.minecraft.client.sounds.AudioStream;
/** Adapts bounded mono PCM frames from Lavaplayer to Minecraft's streaming OpenAL channel. */
/** Downmixes bounded stereo PCM frames from Lavaplayer for Minecraft's mono OpenAL stream. */
final class LavaplayerAudioStream implements AudioStream {
static final int SAMPLE_RATE = 48_000;
private static final int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = 2;
static final int SOURCE_CHANNELS = 2;
static final int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = Short.BYTES;
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
private static final AudioFormat FORMAT =
new AudioFormat(SAMPLE_RATE, 16, 1, true, false);
private final AudioPlayer player;
private byte[] pending = new byte[0];
private int pendingOffset;
private long decodedBytes;
private long paddedSilenceBytes;
private long firstFrameTimecode = -1L;
private long lastFrameTimecode = -1L;
private int decodedFrames;
private boolean initialWaitAttempted;
private volatile boolean closed;
private volatile boolean ended;
@@ -40,20 +50,26 @@ final class LavaplayerAudioStream implements AudioStream {
int length = Math.min(output.remaining(), pending.length - pendingOffset);
output.put(pending, pendingOffset, length);
pendingOffset += length;
decodedBytes += length;
continue;
}
AudioFrame frame = nextFrame();
if (frame == null) {
if (ended) break;
int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE);
for (int index = 0; index < silence; index++) output.put((byte) 0);
continue;
while (output.hasRemaining()) {
output.put((byte) 0);
paddedSilenceBytes++;
}
break;
}
if (frame.isTerminator()) {
ended = true;
break;
}
pending = frame.getData();
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
lastFrameTimecode = frame.getTimecode();
decodedFrames++;
pending = downmixStereoFrame(frame.getData());
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
@@ -61,9 +77,34 @@ final class LavaplayerAudioStream implements AudioStream {
return output;
}
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
}
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
for (int sourceOffset = 0, targetOffset = 0;
sourceOffset < stereo.length;
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
short left = littleEndianSample(stereo, sourceOffset);
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
mono[targetOffset] = (byte) mixed;
mono[targetOffset + 1] = (byte) (mixed >>> 8);
}
return mono;
}
private static short littleEndianSample(byte[] pcm, int offset) {
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
}
private AudioFrame nextFrame() throws IOException {
try {
AudioFrame frame = player.provide(2, TimeUnit.SECONDS);
long waitMillis = initialWaitAttempted
? CONTINUATION_FRAME_WAIT_MILLIS
: INITIAL_FRAME_WAIT_MILLIS;
initialWaitAttempted = true;
AudioFrame frame = player.provide(waitMillis, TimeUnit.MILLISECONDS);
if (frame == null && player.getPlayingTrack() == null) ended = true;
return frame;
} catch (TimeoutException ignored) {
@@ -78,6 +119,23 @@ final class LavaplayerAudioStream implements AudioStream {
return ended;
}
boolean uses(AudioPlayer expectedPlayer) {
return player == expectedPlayer;
}
long decodedBytes() {
return decodedBytes;
}
long paddedSilenceBytes() {
return paddedSilenceBytes;
}
long decodedTimelineMillis() {
if (decodedFrames == 0) return 0L;
return lastFrameTimecode - firstFrameTimecode + 20L;
}
@Override
public void close() {
if (closed) return;
@@ -5,10 +5,12 @@ import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Ios;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor;
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor;
@@ -30,10 +32,14 @@ import net.minecraft.world.phys.Vec3;
/** Client-only, positional and resource-bounded YouTube playback for white discs. */
public final class WhiteDiscAudioPlayer {
private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4;
private static final int MAXIMUM_START_WAIT_TICKS = 100;
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
private static final float ATTENUATION_DISTANCE = 64.0F;
/** OpenAL must preserve the decoded music's native speed and pitch. */
static final float PLAYBACK_PITCH = 1.0F;
private static final DefaultAudioPlayerManager MANAGER = createManager();
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
private static final Map<Long, Long> REQUESTS = new HashMap<>();
private static final LinkedHashMap<Long, Playback> PLAYBACKS = new LinkedHashMap<>();
private static long requestSequence;
@@ -43,16 +49,27 @@ public final class WhiteDiscAudioPlayer {
}
public static void play(Minecraft client, BlockPos pos, String videoId) {
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validTarget(client, pos)) return;
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validJukeboxTarget(client, pos)) return;
long key = pos.asLong();
if (REQUESTS.containsKey(key)) return;
if (PENDING_STARTS.containsKey(key) || REQUESTS.containsKey(key)) return;
boolean replacingPlayback = PLAYBACKS.containsKey(key);
if (!replacingPlayback
&& REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) return;
&& PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()
>= MAXIMUM_SIMULTANEOUS_DISCS) return;
stop(key);
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
if (!validTarget(client, pos)) {
PENDING_STARTS.put(key, new PendingStart(pos.immutable(), client.level.dimension(), videoId,
MAXIMUM_START_WAIT_TICKS));
return;
}
startLoading(client, pos, videoId);
}
private static void startLoading(Minecraft client, BlockPos pos, String videoId) {
long key = pos.asLong();
long request = ++requestSequence;
REQUESTS.put(key, request);
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
@@ -101,24 +118,50 @@ public final class WhiteDiscAudioPlayer {
ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager())
.ambiance$getSoundEngine()).ambiance$getChannelAccess();
channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> {
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request) {
if (handle != null) handle.release();
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request
|| !validTarget(client, pos)) {
if (handle != null) handle.execute(channel -> channel.stop());
if (REQUESTS.getOrDefault(key, -1L) == request) REQUESTS.remove(key);
return;
}
AudioPlayer player = MANAGER.createPlayer();
player.playTrack(track);
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream);
PLAYBACKS.put(key, playback);
REQUESTS.remove(key);
player.addListener(new AudioEventAdapter() {
@Override
public void onTrackException(AudioPlayer failedPlayer, AudioTrack failedTrack,
FriendlyException exception) {
AmbianceMod.LOGGER.warn("White-disc playback failed for YouTube video {}: {}",
failedTrack.getIdentifier(), exception.getMessage());
client.execute(() -> failPlayback(client, key, failedPlayer));
}
@Override
public void onTrackStuck(AudioPlayer stuckPlayer, AudioTrack stuckTrack,
long thresholdMs) {
AmbianceMod.LOGGER.warn("White-disc playback stalled for YouTube video {} after {} ms",
stuckTrack.getIdentifier(), thresholdMs);
client.execute(() -> failPlayback(client, key, stuckPlayer));
}
});
player.playTrack(track);
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
handle.execute(channel -> {
channel.setSelfPosition(Vec3.atCenterOf(pos));
channel.setRelative(false);
channel.linearAttenuation(ATTENUATION_DISTANCE);
channel.setVolume(volume);
channel.attachBufferStream(stream);
channel.play();
try {
channel.setSelfPosition(Vec3.atCenterOf(pos));
channel.setRelative(false);
channel.setPitch(PLAYBACK_PITCH);
channel.linearAttenuation(ATTENUATION_DISTANCE);
channel.setVolume(volume);
channel.attachBufferStream(stream);
channel.play();
} catch (RuntimeException | LinkageError exception) {
AmbianceMod.LOGGER.error("Unable to attach white-disc audio to Minecraft's sound engine",
exception);
client.execute(() -> failPlayback(client, key, player));
}
});
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title));
@@ -132,17 +175,25 @@ public final class WhiteDiscAudioPlayer {
Component.translatable("message.ambiance.white_disc.failed"));
}
private static void failPlayback(Minecraft client, long key, AudioPlayer player) {
Playback playback = PLAYBACKS.get(key);
if (playback == null || !playback.stream().uses(player)) return;
stop(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.failed"));
}
public static void stop(BlockPos pos) {
stop(pos.asLong());
}
private static void stop(long key) {
PENDING_STARTS.remove(key);
REQUESTS.remove(key);
Playback playback = PLAYBACKS.remove(key);
if (playback == null) return;
playback.stream().close();
playback.handle().execute(channel -> channel.stop());
playback.handle().release();
}
public static void tick(Minecraft client) {
@@ -150,6 +201,7 @@ public final class WhiteDiscAudioPlayer {
clear();
return;
}
tickPendingStarts(client);
long now = System.nanoTime();
for (Map.Entry<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
Playback playback = entry.getValue();
@@ -162,11 +214,15 @@ public final class WhiteDiscAudioPlayer {
continue;
}
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
playback.handle().execute(channel -> channel.setVolume(volume));
playback.handle().execute(channel -> {
channel.setPitch(PLAYBACK_PITCH);
channel.setVolume(volume);
});
}
}
public static void clear() {
PENDING_STARTS.clear();
REQUESTS.clear();
for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key);
}
@@ -181,19 +237,55 @@ public final class WhiteDiscAudioPlayer {
private static DefaultAudioPlayerManager createManager() {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.setFrameBufferDuration(5_000);
manager.setItemLoaderThreadPoolSize(2);
manager.registerSourceManager(new YoutubeAudioSourceManager(false));
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
return manager;
}
private static boolean validTarget(Minecraft client, BlockPos pos) {
return validJukeboxTarget(client, pos)
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
}
private static boolean validJukeboxTarget(Minecraft client, BlockPos pos) {
return client.player != null && client.level != null
&& client.player.distanceToSqr(Vec3.atCenterOf(pos))
<= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX)
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX);
}
private static void tickPendingStarts(Minecraft client) {
for (Map.Entry<Long, PendingStart> entry : Map.copyOf(PENDING_STARTS).entrySet()) {
long key = entry.getKey();
PendingStart pending = entry.getValue();
if (!client.level.dimension().equals(pending.dimension())
|| !validJukeboxTarget(client, pending.pos())) {
PENDING_STARTS.remove(key);
continue;
}
if (validTarget(client, pending.pos())) {
PENDING_STARTS.remove(key);
startLoading(client, pending.pos(), pending.videoId());
continue;
}
if (pending.ticksRemaining() <= 1) {
PENDING_STARTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.failed"));
continue;
}
PENDING_STARTS.put(key, pending.waitOneTick());
}
}
private record PendingStart(BlockPos pos, ResourceKey<Level> dimension, String videoId,
int ticksRemaining) {
private PendingStart waitOneTick() {
return new PendingStart(pos, dimension, videoId, ticksRemaining - 1);
}
}
private record Playback(BlockPos pos, ResourceKey<Level> dimension,
@@ -1,19 +1,17 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.LevelEventHandler;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(LevelRenderer.class)
public abstract class LevelRendererMixin {
@Mixin(LevelEventHandler.class)
public abstract class LevelEventHandlerMixin {
@Inject(method = "levelEvent", at = @At("HEAD"))
private void ambiance$stopWhiteDisc(Entity source, int type, BlockPos pos, int data,
CallbackInfo callback) {
private void ambiance$stopWhiteDisc(int type, BlockPos pos, int data, CallbackInfo callback) {
if (type == 1011) WhiteDiscAudioPlayer.stop(pos);
}
}
@@ -11,7 +11,7 @@
"GameRendererMixin",
"LightCoordsUtilMixin",
"LightmapRenderStateExtractorMixin",
"LevelRendererMixin",
"LevelEventHandlerMixin",
"SoundEngineAccessor",
"SoundManagerAccessor",
"SkyRendererMixin"
@@ -32,5 +32,7 @@
"message.ambiance.white_disc.loading": "Loading YouTube video…",
"message.ambiance.white_disc.playing": "Now playing: %s",
"message.ambiance.white_disc.failed": "This YouTube video could not be played.",
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes."
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes.",
"message.ambiance.alpha_textures_missing": "Alpha textures are unavailable: install Golden Days Base then Golden Days Alpha in resourcepacks and leave them disabled.",
"message.ambiance.alpha_textures_restore_failed": "The previous resource-pack stack could not be reloaded. It will be restored on the next start."
}
@@ -32,5 +32,7 @@
"message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…",
"message.ambiance.white_disc.playing": "Lecture : %s",
"message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.",
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes."
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes.",
"message.ambiance.alpha_textures_missing": "Textures Alpha indisponibles : installez Golden Days Base puis Golden Days Alpha dans resourcepacks en les laissant désactivés.",
"message.ambiance.alpha_textures_restore_failed": "La pile de packs précédente na pas pu être rechargée. Elle sera restaurée au prochain démarrage."
}
@@ -32,5 +32,7 @@
"message.ambiance.white_disc.loading": "Загрузка видео YouTube…",
"message.ambiance.white_disc.playing": "Сейчас играет: %s",
"message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.",
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут."
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут.",
"message.ambiance.alpha_textures_missing": "Текстуры Alpha недоступны: установите Golden Days Base, затем Golden Days Alpha в resourcepacks и оставьте их выключенными.",
"message.ambiance.alpha_textures_restore_failed": "Не удалось перезагрузить прежний набор ресурсов. Он будет восстановлен при следующем запуске."
}
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import java.util.List;
/** Executable tests for local Golden Days discovery and exact stack composition. */
public final class AlphaTexturePackManagerSmoke {
private AlphaTexturePackManagerSmoke() {
}
public static void main(String[] args) {
var packs = AlphaTexturePackManager.findPacks(List.of(
"vanilla",
"file/Golden Days Base 16.2 [1.20 to 26.2].zip",
"file/golden_days_alpha_16.2.zip"
)).orElseThrow(() -> new AssertionError("Golden Days packs were not detected"));
List<String> previous = List.of(
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip");
List<String> desired = AlphaTexturePackManager.selectionWithOverlay(previous, packs);
require(desired.equals(List.of("vanilla", "file/custom.zip", packs.base(), packs.alpha())),
"Base and Alpha must be appended in bottom-to-top order without disturbing custom packs");
require(previous.equals(List.of(
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip")),
"Composing the overlay must not mutate the captured stack");
require(AlphaTexturePackManager.findPacks(List.of("file/golden-days-base.zip")).isEmpty(),
"A partial installation must keep the vanilla stack");
require(AlphaTexturePackManager.isGoldenDaysPack("FILE/GOLDEN_DAYS_ALPHA.ZIP"),
"Pack matching must be case- and separator-insensitive");
require(!AlphaTexturePackManager.isGoldenDaysPack("file/custom-alpha.zip"),
"Unrelated packs must never be removed from the captured stack");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,97 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
import java.lang.reflect.Proxy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/** Deterministic check for Minecraft's four one-second streaming buffers. */
public final class LavaplayerAudioStreamSmoke {
private static final int FRAME_SAMPLES = 960;
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
private LavaplayerAudioStreamSmoke() {
}
public static void main(String[] args) throws Exception {
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc playback pitch is not native");
}
Queue<AudioFrame> frames = new ArrayDeque<>();
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
FRAME_SAMPLES, false);
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
byte[] data = new byte[SOURCE_FRAME_BYTES];
for (int offset = 0; offset < data.length; offset += 4) {
putLittleEndian(data, offset, (short) (1_000 + index));
putLittleEndian(data, offset + 2, (short) (3_000 + index));
}
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
}
AtomicInteger timedProvides = new AtomicInteger();
AtomicInteger nonBlockingProvides = new AtomicInteger();
AtomicBoolean destroyed = new AtomicBoolean();
AudioPlayer player = (AudioPlayer) Proxy.newProxyInstance(
AudioPlayer.class.getClassLoader(), new Class<?>[] {AudioPlayer.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "provide" -> {
if (method.getParameterCount() == 2) {
timedProvides.incrementAndGet();
yield frames.poll();
}
nonBlockingProvides.incrementAndGet();
yield null;
}
case "destroy" -> {
destroyed.set(true);
yield null;
}
case "getPlayingTrack" -> null;
case "getVolume" -> 100;
case "isPaused", "startTrack" -> false;
default -> null;
});
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
}
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
short expected = (short) (2_000 + frame);
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
int low = Byte.toUnsignedInt(pcm.get());
short actual = (short) (low | pcm.get() << 8);
if (actual != expected) {
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
}
}
}
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
throw new IllegalStateException("PCM frames were not read with bounded waits");
}
if (stream.getFormat().getSampleRate() != LavaplayerAudioStream.SAMPLE_RATE
|| stream.getFormat().isBigEndian()) {
throw new IllegalStateException("The OpenAL PCM format is not 48 kHz little-endian");
}
stream.close();
if (!destroyed.get()) {
throw new IllegalStateException("Closing the stream did not destroy its player");
}
}
private static void putLittleEndian(byte[] data, int offset, short sample) {
data[offset] = (byte) sample;
data[offset + 1] = (byte) (sample >>> 8);
}
}
@@ -0,0 +1,110 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Ios;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/** Manual network smoke test for the exact YouTube playback path used by white discs. */
public final class WhiteDiscPlaybackSmoke {
private static final String VIDEO_URL = "https://www.youtube.com/watch?v=Ue5ZBe-GzSM";
private WhiteDiscPlaybackSmoke() {
}
public static void main(String[] args) throws Exception {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
try {
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
AudioPlayer player = manager.createPlayer();
player.playTrack(track);
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
int totalBytes = 0;
long squaredSampleSum = 0L;
int sampleCount = 0;
int peakSample = 0;
for (int index = 0; index < 4; index++) {
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
if (pcm == null) break;
totalBytes += pcm.remaining();
while (pcm.remaining() >= 2) {
int low = Byte.toUnsignedInt(pcm.get());
int high = pcm.get();
short sample = (short) ((high << 8) | low);
int magnitude = Math.abs((int) sample);
peakSample = Math.max(peakSample, magnitude);
squaredSampleSum += (long) sample * sample;
sampleCount++;
}
}
int expectedBytes = LavaplayerAudioStream.SAMPLE_RATE * 2 * 4;
if (totalBytes != expectedBytes) {
throw new IllegalStateException("YouTube playback did not fill four PCM seconds");
}
if (stream.decodedBytes() != expectedBytes || stream.paddedSilenceBytes() != 0L) {
throw new IllegalStateException("YouTube playback was not 100% decoded PCM: "
+ stream.decodedBytes() + " decoded bytes and "
+ stream.paddedSilenceBytes() + " padded bytes");
}
if (stream.decodedTimelineMillis() != 4_000L) {
throw new IllegalStateException("PCM cadence mismatch: four seconds of samples cover "
+ stream.decodedTimelineMillis() + " ms of the YouTube timeline");
}
double rmsSample = Math.sqrt((double) squaredSampleSum / sampleCount);
if (rmsSample < 256.0 || peakSample < 2_048) {
throw new IllegalStateException("YouTube PCM signal is too quiet: RMS "
+ Math.round(rmsSample) + ", peak " + peakSample);
}
System.out.printf(
"PCM cadence: 100%% speed, 4000 ms decoded into 4000 ms at %.0f Hz; RMS %.0f, peak %d from %s%n",
stream.getFormat().getSampleRate(), rmsSample, peakSample, track.getInfo().title);
stream.close();
} finally {
manager.shutdown();
}
}
private static CompletableFuture<AudioTrack> load(DefaultAudioPlayerManager manager) {
CompletableFuture<AudioTrack> result = new CompletableFuture<>();
manager.loadItem(VIDEO_URL, new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
result.complete(track);
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
AudioTrack selected = playlist.getSelectedTrack();
if (selected == null && !playlist.getTracks().isEmpty()) {
selected = playlist.getTracks().getFirst();
}
if (selected == null) result.completeExceptionally(
new IllegalStateException("YouTube playlist contained no track"));
else result.complete(selected);
}
@Override
public void noMatches() {
result.completeExceptionally(new IllegalStateException("YouTube returned no match"));
}
@Override
public void loadFailed(FriendlyException exception) {
result.completeExceptionally(exception);
}
});
return result;
}
}
@@ -10,6 +10,8 @@ public final class WhiteDiscUrlSmoke {
public static void main(String[] args) {
require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected");
require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected");
require("Ue5ZBe-GzSM".equals(id("https://youtu.be/Ue5ZBe-GzSM?si=78LSbtBQzUYzhA2Y")),
"reported white-disc URL rejected");
require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected");
require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected");
require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected");
+35 -2
View File
@@ -1647,6 +1647,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort nest pas actif.",
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif avant den braquer un autre.",
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
@@ -1671,6 +1672,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
"message.anotherworld.safe_vault.raid_requires_vault": "Для ограбления чужого сейфа нужен собственный активный сейф.",
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
@@ -1695,6 +1697,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
"message.anotherworld.safe_vault.raid_requires_vault": "You need an active safe of your own before raiding another one.",
"message.anotherworld.safe_vault.empty": "This safe is empty.",
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
@@ -1953,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.55"
if (project.version.toString() != "0.0.0-alpha.57"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3270,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.55"
if (project.version.toString() != "0.0.0-alpha.57"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -3297,3 +3300,33 @@ tasks.register("verifyClimateBiomeMixer") {
tasks.named("check") {
dependsOn tasks.named("verifyClimateBiomeMixer")
}
tasks.register("verifyBackroomMaterialIntegration") {
group = "verification"
description = "Checks the one-way Another World material integration with Sanctuary Backroom."
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java"))
inputs.file(rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java"))
doLast {
def initializer = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java").text
def blocks = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java").text
def api = rootProject.file(
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text
def rootBuild = rootProject.file("build.gradle").text
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (project.version.toString() != "0.0.0-alpha.57"
|| !initializer.contains("BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS)")
|| !blocks.contains('"turquoise_moss"')
|| !api.contains("public static void registerAnomalyMaterial")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.131"
|| !rootBuild.contains('project(":anotherworld")')
|| !rootBuild.contains('implementation project(":sanctuary")')) {
throw new GradleException("Another World alpha.57 Backroom material integration is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyBackroomMaterialIntegration")
}
@@ -4,7 +4,9 @@ import fr.koka99cab.canon.CanonMod;
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
import fr.koka99cab.sanctuary26.anotherworld.network.AnotherWorldNetworking;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldRegistries;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService;
import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
@@ -26,6 +28,7 @@ public final class AnotherWorldMod implements ModInitializer {
@Override
public void onInitialize() {
AnotherWorldRegistries.initialize();
BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS);
VaultService.initialize();
WeatherCompassItem.registerFrameInteraction();
CanonMod.initialize();
@@ -113,6 +113,12 @@ public final class VaultData extends SavedData {
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
}
public boolean hasRegisteredVault(UUID owner) {
if (owner == null) return false;
String ownerId = owner.toString();
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
}
public void unregisterNear(ServerLevel level, BlockPos member) {
String dimension = dimension(level);
boolean changed = vaults.removeIf(address -> {
@@ -163,7 +169,7 @@ public final class VaultData extends SavedData {
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
public long transferRaid(UUID owner, UUID attacker) {
if (owner == null || attacker == null || owner.equals(attacker)) return 0L;
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
Account source = accounts.get(owner);
if (source == null) return 0L;
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
@@ -180,6 +186,10 @@ public final class VaultData extends SavedData {
return total;
}
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
}
public long protectionRemaining(UUID owner, long gameTime) {
Account account = accounts.get(owner);
return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime);
@@ -48,6 +48,10 @@ public final class VaultDrillingService {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
return false;
}
if (!data.hasRegisteredVault(attacker.getUUID())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
return false;
}
if (!data.hasContents(formed.owner())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
return false;
@@ -104,6 +108,13 @@ public final class VaultDrillingService {
attacker.stopUsingItem();
return;
}
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
cancel(attacker, false);
attacker.sendOverlayMessage(Component.translatable(
"message.anotherworld.safe_vault.raid_requires_vault"));
attacker.stopUsingItem();
return;
}
int elapsed = session.elapsedTicks() + 1;
Session progressed = session.withElapsed(elapsed);
@@ -31,7 +31,7 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"ambiance": "*",
"sanctuary": ">=0.0.0-alpha.117"
"sanctuary": ">=0.0.0-alpha.131"
},
"custom": {
"sanctuary26": {
@@ -1,5 +1,7 @@
package fr.koka99cab.sanctuary26.anotherworld.vault;
import java.util.UUID;
public final class VaultRulesSmoke {
private VaultRulesSmoke() {
}
@@ -55,6 +57,14 @@ public final class VaultRulesSmoke {
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
"64-bit balance raid without overflow");
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
"an attacker without a registered vault cannot receive raid loot");
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
"an attacker with a registered vault can receive raid loot");
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
"a vault owner cannot receive loot from their own vault");
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
"balance saturation without overflow");
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
+2160 -175
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -12,23 +12,23 @@ fabric_api_version=0.158.0+26.2
# Standby modules stay at zero until their migration really begins.
mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.15
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.10
redstoner_version=0.0.0-alpha.13
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.55
anotherworld_version=0.0.0-alpha.57
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.36
itsalive_version=0.0.0-alpha.38
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.15
iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.24
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.119
sanctuary_version=0.0.0-alpha.131
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.210
pack_version=26.2.0-alpha.226
maven_group=fr.koka99cab.sanctuary26
+66
View File
@@ -0,0 +1,66 @@
# Magic Carpet et Totem de Notch
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
catalogue et applique le jalon avant d'exposer l'offre.
## Identités et provenance
| Rôle | ID | Statut |
|---|---|---|
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
convertie une seconde fois.
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
diagnostic et à l'infobulle.
## Première récompense
Après la première victoire mondiale, Sanctuary réserve atomiquement la
récompense puis I Like To Move It crée un coffre déterministe près de la maison
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
créé ce coffre.
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
Une reprise entre les deux relance la création de façon idempotente ; un coffre
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
l'état livré afin de ne rien dupliquer.
## Plateforme temporaire
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
dégât de chute est réinitialisé tant que la plateforme est active.
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
changement de dimension et pendant l'arrêt normal du serveur.
## Remplacement au Shop
Only Fun associe le Magic Carpet au resolver public
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
Après la victoire, elle rejoint la rotation normale au prix configuré de
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
paient chaque remplacement et la Mailbox effectue la livraison différée.
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
et ne touche jamais aux états de réservation ou de coffre de la récompense
initiale.
+10 -2
View File
@@ -29,7 +29,7 @@ Une route est un JSON sous `data/<namespace>/sanctuary_portal/<id>.json` :
- `arrival.type` vaut `surface`, `matched_y` ou `fixed_y`; ce dernier exige `arrival.y`.
- `priority` départage deux routes acceptant le même cadre.
Les neuf routes historiques restent déclarées pour la compatibilité. Pour l'alpha.150, seules deux routes sont actives : terre vers la paire Sanctuary/Overworld et pierre vers `sanctuary:caverns`. Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
Les neuf routes historiques restent déclarées pour la compatibilité. Depuis l'alpha.219, trois routes sont actives : terre vers la paire Sanctuary/Overworld, pierre vers `sanctuary:caverns` et cobblestone moussue vers `sanctuary:alpha`. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, elle relie `minecraft:overworld` à `sanctuary:overworld`. Dans le profil Overworld, elle relie `minecraft:overworld` à `sanctuary:main`. Le choix appartient à la sauvegarde, jamais à une préférence cliente.
@@ -43,8 +43,16 @@ La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, ell
4. Reconstruire le même cadre réactive son endpoint; la traversée suivante régénère un lien.
5. Une destination absente refuse la traversée proprement. Elle ne crée aucune dimension et n'endommage pas la sauvegarde.
La route Alpha exige un cadre extérieur de 4 × 5 avec un intérieur vide de
2 × 3, entièrement en cobblestone moussue, puis un briquet. Le serveur valide
le cadre, l'activateur et la présence de `sanctuary:alpha` avant de l'allumer.
Sa première moitié distante utilise l'arrivée sûre calculée par l'API publique
de Sanctuary, hors de la maison Indev ; le client ne choisit ni la destination
ni les coordonnées. L'Atlas découvre le bloc de portail actif comme les autres
points d'intérêt locaux.
Le bloc intérieur actif est `iliketomoveit:portal`. Les neuf anciens blocs `portals:*_portal` restent enregistrés comme compatibilité de lecture; une première utilisation les rattache au nouvel index.
## État des générateurs
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns, Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Leurs cadres peuvent être allumés et persistés ; une traversée vers une destination encore absente est refusée proprement.
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns et Alpha sont également générés ; Alpha est le monde Indev fini 128 × 128 × 64 possédé par Sanctuary. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Une traversée vers une destination absente est refusée proprement dès l'allumage.
+68 -11
View File
@@ -24,6 +24,22 @@ tasks.register("happyGhastCruiseSpeedSmoke", JavaExec) {
dependsOn tasks.named("testClasses")
}
tasks.register("alphaPortalContractSmoke", JavaExec) {
group = "verification"
description = "Checks the active Alpha route and its finite-world safe-arrival contract."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.portal.AlphaPortalContractSmoke"
dependsOn tasks.named("testClasses")
}
tasks.register("magicCarpetLedgerSmoke", JavaExec) {
group = "verification"
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
dependsOn tasks.named("testClasses")
}
tasks.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -92,11 +108,11 @@ tasks.register("verifyWaystones") {
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 3
if (manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -195,9 +211,9 @@ tasks.register("verifyGrappling") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|| manifest.custom?.sanctuary26?.data_version != 3
|| manifest.custom?.sanctuary26?.data_version != 4
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
throw new GradleException("I Like To Move It must keep aggregate data v3/protocol 5 and register its common/client mixins")
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
}
["fr_fr", "en_us", "ru_ru"].each { language ->
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
@@ -268,7 +284,7 @@ tasks.register("verifyPortals") {
def routeFile = file("src/main/resources/data/iliketomoveit/sanctuary_portal/${route}.json")
if (!routeFile.isFile()) throw new GradleException("Missing portal route ${route}")
def json = new JsonSlurper().parse(routeFile)
def expectedSchema = route == 'overworld' ? 2 : 1
def expectedSchema = route in ['overworld', 'alpha'] ? 2 : 1
if (json.schema_version != expectedSchema || json.destination_dimension != destination
|| json.source_dimension != "minecraft:overworld"
|| (route == 'overworld' && json.destination_resolver != "overworld_pair")
@@ -305,7 +321,7 @@ tasks.register("verifyPortals") {
}
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
if (project.version.toString() != "0.0.0-alpha.15"
if (project.version.toString() != "0.0.0-alpha.18"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -313,7 +329,7 @@ tasks.register("verifyPortals") {
|| atlasCavernsMigration.portals?.inactive_routes?.size() != 7
|| atlasCavernsMigration.portals?.reserved_ids_retained != true
|| atlasCavernsMigration.portals?.inactive_routes_loadable != false
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS)")
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|| !javaText.contains("REQUIRED_ROUTES = ACTIVE_ROUTES")
|| atlasCavernsMigration.compatibility?.iliketomoveit_data_version != 3
|| atlasCavernsMigration.compatibility?.iliketomoveit_network_protocol != 3) {
@@ -341,7 +357,7 @@ tasks.register("verifySilverAircraft") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
if (project.version.toString() != "0.0.0-alpha.15"
if (project.version.toString() != "0.0.0-alpha.18"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -370,7 +386,7 @@ tasks.register("verifyGrapplePrediction") {
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
if (project.version.toString() != "0.0.0-alpha.15"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
@@ -402,7 +418,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
def teleportation = file(
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
if (project.version.toString() != "0.0.0-alpha.15"
if (project.version.toString() != "0.0.0-alpha.18"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
@@ -418,8 +434,47 @@ tasks.register("verifyWaystoneDescendingRelease") {
}
}
tasks.register("verifyAlphaPortal") {
group = "verification"
description = "Checks the alpha.16 mossy Alpha portal lifecycle and server authority."
inputs.file(file("PORTALS.md"))
inputs.file(file("src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
inputs.files(fileTree("src/main/resources/assets/iliketomoveit/lang") { include "*.json" })
inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal") { include "*.java" })
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
doLast {
def route = new JsonSlurper().parse(file(
"src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
def javaText = fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal")
.matching { include "*.java" }.files*.text.join("\n")
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.18"
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|| route.destination_dimension != "sanctuary:alpha"
|| route.destination_resolver != "fixed"
|| route.frame_blocks != ["minecraft:mossy_cobblestone"]
|| route.activation_items != ["minecraft:flint_and_steel"]
|| migration.alpha_portal?.outer_width != 4 || migration.alpha_portal?.outer_height != 5
|| migration.alpha_portal?.inner_width != 2 || migration.alpha_portal?.inner_height != 3
|| migration.alpha_portal?.inactive_when_destination_absent != true
|| migration.alpha_portal?.persistent_pair_reused != true
|| migration.alpha_portal?.break_unlinks_pair != true
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|| !javaText.contains("SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed())")
|| !javaText.contains("getServer().getLevel(")
|| locales.any { !it.containsKey("message.iliketomoveit.portal.destination_unavailable") }) {
throw new GradleException("I Like To Move It alpha.16 Alpha portal contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("grapplePhysicsSmoke")
dependsOn tasks.named("magicCarpetLedgerSmoke")
dependsOn tasks.named("waystoneElevatorRulesSmoke")
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
dependsOn tasks.named("verifyWaystones")
@@ -428,4 +483,6 @@ tasks.named("check") {
dependsOn tasks.named("verifyWaystoneDescendingRelease")
dependsOn tasks.named("verifyPortals")
dependsOn tasks.named("verifySilverAircraft")
dependsOn tasks.named("alphaPortalContractSmoke")
dependsOn tasks.named("verifyAlphaPortal")
}
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
public final class ILikeToMoveItMod implements ModInitializer {
public static final String MOD_ID = "iliketomoveit";
public static final String DISPLAY_NAME = "I Like To Move It";
public static final int CURRENT_DATA_VERSION = 3;
public static final int CURRENT_DATA_VERSION = 4;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ILikeToMoveItMod() {
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
GrappleController.register();
PulleyHeadController.register();
MagicCarpetController.register();
MagicCarpetMigrationService.register();
NotchCarpetRewardService.register();
WaystoneElevatorController.initialize();
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.iliketomoveit.item;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
public final class MagicCarpetItem extends Item {
public MagicCarpetItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
.withStyle(ChatFormatting.GRAY));
var origin = SanctuaryItemProvenance.origin(stack);
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
.withStyle(ChatFormatting.GOLD));
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
.withStyle(ChatFormatting.AQUA));
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@Override
public boolean isFoil(ItemStack stack) {
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|| super.isFoil(stack);
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.Container;
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
import net.minecraft.world.level.storage.ValueInput;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
@Mixin(BaseContainerBlockEntity.class)
abstract class MagicCarpetContainerMixin {
@Inject(method = "loadAdditional", at = @At("TAIL"))
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
MagicCarpetMigration.migrateContainer((Container) (Object) this);
}
}
@@ -0,0 +1,21 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Converts historical Totems already present as loaded item entities. */
@Mixin(ItemEntity.class)
abstract class MagicCarpetItemEntityMixin {
@Inject(method = "tick", at = @At("HEAD"))
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
ItemEntity self = (ItemEntity) (Object) this;
ItemStack current = self.getItem();
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
if (replacement != current) self.setItem(replacement);
}
}
@@ -26,7 +26,7 @@ public final class PortalDefinitions {
public static final Identifier LAPUTA = ILikeToMoveItMod.id("laputa");
/** Routes enabled for the current gameplay milestone. Other stable IDs stay reserved. */
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS);
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA);
public static final List<Identifier> REQUIRED_ROUTES = ACTIVE_ROUTES;
private static volatile Map<Identifier, PortalDefinition> definitions = Map.of();
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
import java.util.Optional;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
@@ -37,6 +38,12 @@ public final class PortalIgnitionHandler {
}
ServerLevel serverLevel = (ServerLevel) level;
if (serverLevel.getServer().getLevel(
selected.targetDimension(serverLevel.getServer(), serverLevel.dimension())) == null) {
player.sendOverlayMessage(Component.translatable(
"message.iliketomoveit.portal.destination_unavailable"));
return InteractionResult.FAIL;
}
PortalEndpoint endpoint = PortalSavedData.get(serverLevel.getServer())
.register(serverLevel, selectedFrame, selected.id());
selectedFrame.fill(serverLevel, selected, endpoint, ILikeToMoveItBlocks.PORTAL);
@@ -3,6 +3,8 @@ package fr.koka99cab.sanctuary26.iliketomoveit.portal;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.block.PortalBlock;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import java.util.Optional;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
@@ -92,6 +94,9 @@ public final class PortalTeleporter {
private static BlockPos targetCenter(ServerLevel sourceLevel, ServerLevel targetLevel,
PortalFrame sourceFrame, PortalDefinition definition) {
if (targetLevel.dimension().equals(SanctuaryDimensions.ALPHA)) {
return SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed());
}
double scale = definition.scaleFrom(sourceLevel.dimension());
int x = Mth.floor(sourceFrame.center().getX() * scale);
int z = Mth.floor(sourceFrame.center().getZ() * scale);
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
public static final Item MAGIC_CARPET = register("magic_carpet",
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
public static final Item LEGACY_NOTCH_TOTEM = register(
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
public static final Item GRAPPLING_HAND = register("grappling_hand",
key -> new Item(new Item.Properties().setId(key)));
public static final Item GRAPPLING_ROD = register("grappling_rod",
@@ -1,10 +1,7 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
private static final int RADIUS = 1;
private static final Block CARPET_BLOCK = Blocks.GLASS;
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
private MagicCarpetController() {
}
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
connected.add(id);
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
if (active) {
if (!ACTIVE_CARPETS.containsKey(id)) {
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
if (!ACTIVE_CARPETS.contains(id)) {
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
}
updateCarpet(id, player);
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
deactivate(server, id, true);
}
}
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, CarpetState> entry = iterator.next();
if (!connected.contains(entry.getKey())) {
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
iterator.remove();
for (UUID owner : ACTIVE_CARPETS.owners()) {
if (!connected.contains(owner)) {
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state != null) removePositions(server, owner, state, state.positions());
}
}
}
private static boolean isMagicCarpet(ItemStack stack) {
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
return MagicCarpetMigration.isAnyCarpet(stack);
}
private static void updateCarpet(UUID owner, ServerPlayer player) {
ServerLevel level = player.level();
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
if (!previous.dimension().equals(level.dimension())) {
removePositions(level.getServer(), owner, previous, previous.positions());
previous = CarpetState.empty(level.dimension());
ACTIVE_CARPETS.remove(owner);
previous = ACTIVE_CARPETS.state(owner, level.dimension());
} else {
Set<BlockPos> stale = new HashSet<>(previous.positions());
stale.removeAll(target);
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
placed.add(pos);
}
}
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
player.resetFallDistance();
}
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
}
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
CarpetState state = ACTIVE_CARPETS.remove(owner);
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
if (state == null) return;
removePositions(server, owner, state, state.positions());
ServerPlayer player = server.getPlayerList().getPlayer(owner);
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
}
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
ServerLevel level = server.getLevel(state.dimension());
if (level == null) return;
for (BlockPos pos : positions) {
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
}
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
}
private static void reset(MinecraftServer server) {
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
ACTIVE_CARPETS.clear();
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
}
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
}
}
@@ -0,0 +1,56 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
public final class MagicCarpetLedger {
private final Map<UUID, State> carpets = new HashMap<>();
public boolean contains(UUID owner) {
return carpets.containsKey(owner);
}
public State state(UUID owner, ResourceKey<Level> dimension) {
return carpets.getOrDefault(owner, State.empty(dimension));
}
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
}
public State remove(UUID owner) {
return carpets.remove(owner);
}
public Set<UUID> owners() {
return Set.copyOf(carpets.keySet());
}
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
&& entry.getValue().dimension().equals(dimension)
&& entry.getValue().positions().contains(pos));
}
public Map<UUID, State> drain() {
Map<UUID, State> copy = Map.copyOf(carpets);
carpets.clear();
return copy;
}
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
public State {
positions = Set.copyOf(positions);
}
private static State empty(ResourceKey<Level> dimension) {
return new State(dimension, Set.of());
}
}
}
@@ -0,0 +1,39 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
public final class MagicCarpetMigration {
private MagicCarpetMigration() {
}
public static boolean isAnyCarpet(ItemStack stack) {
return stack != null && !stack.isEmpty()
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
}
public static ItemStack migrateStack(ItemStack stack) {
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
return migrated;
}
public static int migrateContainer(Container container) {
if (container == null) return 0;
int migrated = 0;
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack current = container.getItem(slot);
ItemStack replacement = migrateStack(current);
if (replacement == current) continue;
container.setItem(slot, replacement);
migrated++;
}
if (migrated > 0) container.setChanged();
return migrated;
}
}
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Converts online inventories once their historical alias has been decoded. */
public final class MagicCarpetMigrationService {
private MagicCarpetMigrationService() {
}
public static void register() {
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
}
private static void tick(MinecraftServer server) {
ILikeToMoveItData.get(server);
if (server.overworld().getGameTime() % 20L != 0L) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
}
private static void migrate(ServerPlayer player) {
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
if (migrated > 0) {
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
migrated, player.getGameProfile().name());
}
}
}
@@ -0,0 +1,124 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.List;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.FireworkExplosion;
import net.minecraft.world.item.component.Fireworks;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
public final class NotchCarpetRewardService {
private static final int REWARD_SLOT = 13;
private NotchCarpetRewardService() {
}
public static void register() {
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
}
private static void tick(MinecraftServer server) {
if (server.overworld().getGameTime() % 20L != 0L) return;
var state = SanctuaryNotchMilestone.snapshot(server);
if (!state.defeated() || state.initialRewardDelivered()) return;
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
if (alpha == null) return;
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
if (!alpha.isLoaded(home)) return;
RewardChest result = ensureRewardChest(alpha, home);
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
if (result.created()) {
launchFirework(alpha, result.pos());
server.getPlayerList().broadcastSystemMessage(
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
}
ILikeToMoveItMod.LOGGER.info(
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
result.pos().toShortString(), !result.created());
}
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
List<BlockPos> candidates = candidates(home);
for (BlockPos pos : candidates) {
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
}
for (BlockPos pos : candidates) {
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|| level.getBlockState(pos.below()).isAir()) continue;
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
chest.setItem(REWARD_SLOT, rewardStack());
chest.setChanged();
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
return new RewardChest(pos, true);
}
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
return null;
}
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
for (ItemStack stack : container) {
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
}
return false;
}
private static ItemStack rewardStack() {
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
reward.set(DataComponents.CUSTOM_NAME,
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
return reward;
}
private static List<BlockPos> candidates(BlockPos home) {
ArrayList<BlockPos> positions = new ArrayList<>();
for (int y : new int[] {0, 1, -1, 2}) {
for (int radius = 2; radius <= 7; radius++) {
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
BlockPos candidate = home.offset(x, y, z).immutable();
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
}
}
}
return List.copyOf(positions);
}
private static void launchFirework(ServerLevel level, BlockPos pos) {
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
new IntArrayList(new int[] {0x9B59B6}), true, true);
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
}
private record RewardChest(BlockPos pos, boolean created) {
}
}
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Aircraft",
"item.iliketomoveit.magic_cloud": "Magic Cloud",
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
"item.iliketomoveit.magic_carpet": "Magic Carpet",
"item.portals.notch_totem": "Notch Totem (legacy)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
"block.iliketomoveit.waystone": "Waystone",
"block.waystone.waystone": "Waystone (legacy)",
"entity.iliketomoveit.aircraft": "Aircraft",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
"screen.iliketomoveit.waystone.title": "Waystones",
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Avion",
"item.iliketomoveit.magic_cloud": "Nuage magique",
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
"item.iliketomoveit.magic_carpet": "Tapis magique",
"item.portals.notch_totem": "Totem de Notch (hérité)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
"block.iliketomoveit.waystone": "Pierre de voyage",
"block.waystone.waystone": "Pierre de voyage (héritée)",
"entity.iliketomoveit.aircraft": "Avion",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
@@ -3,7 +3,13 @@
"itemGroup.iliketomoveit.transport": "I Like To Move It",
"item.iliketomoveit.aircraft": "Самолёт",
"item.iliketomoveit.magic_cloud": "Волшебное облако",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
"item.portals.notch_totem": "Тотем Нотча (старый)",
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
"block.iliketomoveit.waystone": "Камень пути",
"block.waystone.waystone": "Камень пути (старый)",
"entity.iliketomoveit.aircraft": "Самолёт",
@@ -11,6 +17,7 @@
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
"screen.iliketomoveit.waystone.title": "Камни пути",
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
@@ -1,6 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:block/purple_wool"
"layer0": "iliketomoveit:item/magic_carpet"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "iliketomoveit:item/magic_carpet"
}
}
@@ -1,7 +1,8 @@
{
"schema_version": 1,
"schema_version": 2,
"source_dimension": "minecraft:overworld",
"destination_dimension": "sanctuary:alpha",
"destination_resolver": "fixed",
"frame_blocks": ["minecraft:mossy_cobblestone"],
"preferred_frame_block": "minecraft:mossy_cobblestone",
"activation_items": ["minecraft:flint_and_steel"],
@@ -33,13 +33,13 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"anotherworld": ">=0.0.0-alpha.46",
"sanctuary": ">=0.0.0-alpha.21"
"sanctuary": ">=0.0.0-alpha.127"
},
"custom": {
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "iliketomoveit",
"data_version": 3,
"data_version": 4,
"network_protocol": 5,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
@@ -3,7 +3,9 @@
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin"
"HappyGhastMixin",
"MagicCarpetContainerMixin",
"MagicCarpetItemEntityMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,37 @@
package fr.koka99cab.sanctuary26.iliketomoveit.portal;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import java.util.Set;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
/** Executable cross-module contract for the active mossy Alpha portal. */
public final class AlphaPortalContractSmoke {
private AlphaPortalContractSmoke() {
}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
require(PortalDefinitions.ACTIVE_ROUTES.equals(PortalDefinitions.REQUIRED_ROUTES),
"Every active portal definition must be required at reload");
require(Set.copyOf(PortalDefinitions.ACTIVE_ROUTES).equals(Set.of(
PortalDefinitions.OVERWORLD, PortalDefinitions.CAVERNS, PortalDefinitions.ALPHA)),
"Exactly the Overworld, Caverns and Alpha routes must be active");
for (long seed : new long[] { 0L, 1L, 46L }) {
BlockPos arrival = SanctuaryAlphaWorld.portalArrival(seed);
BlockPos residence = SanctuaryAlphaWorld.residence(seed);
require(SanctuaryAlphaWorld.containsHorizontal(arrival),
"Server-selected Alpha arrival must stay inside the finite island");
require(Math.max(Math.abs(arrival.getX() - residence.getX()),
Math.abs(arrival.getZ() - residence.getZ())) >= 10,
"The remote portal must not damage the Indev residence");
}
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,45 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.Level;
/** Regression checks for overlap, world changes and shutdown cleanup ownership. */
public final class MagicCarpetLedgerSmoke {
private MagicCarpetLedgerSmoke() {
}
public static void main(String[] args) {
MagicCarpetLedger ledger = new MagicCarpetLedger();
UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
ResourceKey<Level> firstWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "first"));
ResourceKey<Level> secondWorld = ResourceKey.create(Registries.DIMENSION,
Identifier.fromNamespaceAndPath("test", "second"));
BlockPos shared = new BlockPos(4, 70, 4);
ledger.track(first, firstWorld, Set.of(shared));
ledger.track(second, firstWorld, Set.of(shared));
if (!ledger.trackedByAnother(first, firstWorld, shared)) {
throw new AssertionError("Overlapping carpets lost their second owner");
}
ledger.remove(first);
if (ledger.trackedByAnother(second, firstWorld, shared)) {
throw new AssertionError("Removed owner still controls the shared glass");
}
ledger.track(second, secondWorld, Set.of(new BlockPos(0, 64, 0)));
if (!ledger.state(second, secondWorld).dimension().equals(secondWorld)
|| !ledger.state(second, firstWorld).dimension().equals(secondWorld)) {
throw new AssertionError("Dimension change did not replace the tracked world");
}
Map<UUID, MagicCarpetLedger.State> shutdown = ledger.drain();
if (shutdown.size() != 1 || !ledger.owners().isEmpty()) {
throw new AssertionError("Server shutdown did not atomically drain temporary platforms");
}
}
}
+9 -1
View File
@@ -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
+15 -6
View File
@@ -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.38"
|| 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.38"
|| 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.38"
|| 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.38"
|| 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.38"
|| 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.38"
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|| !knowledge.contains("data.personal(inscription.ownerId())")
@@ -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();
@@ -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);
}
}
@@ -34,7 +34,8 @@ public final class CanapliaHarvestService {
public static void onMatureHarvest(ServerLevel level, ServerPlayer player, BlockPos pos, BlockState state) {
if (!state.is(CanapliaContent.HEMP_CROP) || !CanapliaContent.HEMP_CROP.isMaxAge(state)) return;
CanapliaVariantKey parent = state.getValue(CanapliaHempCropBlock.VARIANT);
CanapliaVariantKey parent = CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT));
CanapliaKnowledge.discover(player, parent, "harvest");
ClimateCropForaging.CropClimate climate = ClimateCropForaging.climate(level.getBiome(pos));
AmbianceLunarHarvestApi.Conditions lunar = AmbianceLunarHarvestApi.evaluate(level, pos);
@@ -65,7 +66,8 @@ public final class CanapliaHarvestService {
if (pos.equals(center)) continue;
BlockState state = level.getBlockState(pos);
if (state.is(CanapliaContent.HEMP_CROP) && CanapliaContent.HEMP_CROP.isMaxAge(state)) {
variants.add(state.getValue(CanapliaHempCropBlock.VARIANT));
variants.add(CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT)));
}
}
return Set.copyOf(variants);
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
/** Stable variant hand-off across placement, growth, block-state persistence and harvest. */
public final class CanapliaVariantLifecycle {
private CanapliaVariantLifecycle() {
}
public static CanapliaVariantKey plantedFromSeed(CanapliaVariantKey seedVariant) {
return require(seedVariant);
}
public static CanapliaVariantKey afterGrowth(CanapliaVariantKey currentVariant) {
return require(currentVariant);
}
public static CanapliaVariantKey upperFromLower(CanapliaVariantKey lowerVariant) {
return require(lowerVariant);
}
public static CanapliaVariantKey harvestedFromPlant(CanapliaVariantKey plantVariant) {
return require(plantVariant);
}
public static String persistedKey(CanapliaVariantKey variant) {
return require(variant).key();
}
public static CanapliaVariantKey fromPersistedKey(String key) {
return CanapliaVariantKey.byKey(key).orElse(CanapliaVariantKey.COMMON);
}
private static CanapliaVariantKey require(CanapliaVariantKey variant) {
if (variant == null) throw new IllegalArgumentException("Canaplia variant cannot be null");
return variant;
}
}
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaHarvestService;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
@@ -43,19 +44,28 @@ public final class CanapliaHempCropBlock extends CropBlock {
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(VARIANT)).seeds());
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
return new ItemStack(CanapliaContent.family(variant).seeds());
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
super.randomTick(state, level, pos, random);
synchronizeUpper(level, pos, level.getBlockState(pos));
if (level.getRawBrightness(pos, 0) < 9) return;
int age = getAge(state);
if (age >= getMaxAge()) return;
float speed = getGrowthSpeed(this, level, pos);
if (random.nextInt((int) (25.0F / speed) + 1) != 0) return;
BlockState grown = state.setValue(AGE, age + 1).setValue(VARIANT,
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, grown);
}
@Override
public void growCrops(Level level, BlockPos pos, BlockState state) {
int age = Math.min(getMaxAge(), getAge(state) + getBonemealAgeIncrease(level));
BlockState grown = state.setValue(AGE, age);
BlockState grown = state.setValue(AGE, age).setValue(VARIANT,
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, grown);
}
@@ -78,7 +88,7 @@ public final class CanapliaHempCropBlock extends CropBlock {
if (!(level instanceof ServerLevel serverLevel) || !(player instanceof ServerPlayer serverPlayer)) {
return InteractionResult.FAIL;
}
CanapliaVariantKey variant = state.getValue(VARIANT);
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
Block.popResource(serverLevel, pos,
new ItemStack(CanapliaContent.family(variant).bud(), 1 + serverLevel.getRandom().nextInt(3)));
CanapliaHarvestService.onMatureHarvest(serverLevel, serverPlayer, pos, state);
@@ -97,7 +107,8 @@ public final class CanapliaHempCropBlock extends CropBlock {
if (getAge(state) >= UPPER_START_AGE) {
if (aboveState.isAir() || aboveState.is(CanapliaContent.HEMP_TOP)) {
level.setBlock(above, CanapliaContent.HEMP_TOP.defaultBlockState()
.setValue(AGE, getAge(state)).setValue(VARIANT, state.getValue(VARIANT)), Block.UPDATE_CLIENTS);
.setValue(AGE, getAge(state)).setValue(VARIANT,
CanapliaVariantLifecycle.upperFromLower(state.getValue(VARIANT))), Block.UPDATE_CLIENTS);
}
} else if (aboveState.is(CanapliaContent.HEMP_TOP)) {
level.removeBlock(above, false);
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
@@ -26,7 +27,9 @@ public final class CanapliaHempTopBlock extends CropBlock {
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(CanapliaHempCropBlock.VARIANT)).seeds());
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(
state.getValue(CanapliaHempCropBlock.VARIANT));
return new ItemStack(CanapliaContent.family(variant).seeds());
}
@Override
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
@@ -24,12 +25,17 @@ public final class CanapliaVariantBlockItem extends BlockItem {
@Override
protected BlockState getPlacementState(BlockPlaceContext context) {
BlockState state = super.getPlacementState(context);
if (state == null) return null;
return applyVariant(state, variant);
}
public static BlockState applyVariant(BlockState state, CanapliaVariantKey variant) {
if (state == null || variant == null) return state;
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
if (state.hasProperty(CanapliaHempCropBlock.VARIANT)) {
return state.setValue(CanapliaHempCropBlock.VARIANT, variant);
return state.setValue(CanapliaHempCropBlock.VARIANT, placed);
}
if (state.hasProperty(CanapliaResinBlock.VARIANT)) {
return state.setValue(CanapliaResinBlock.VARIANT, variant);
return state.setValue(CanapliaResinBlock.VARIANT, placed);
}
return state;
}
@@ -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
@@ -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<NotchRenderState> {
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);
}
}
@@ -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<NotchEntity, NotchRenderState, NotchModel> {
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);
}
}
@@ -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;
}
@@ -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<Byte> PHASE = SynchedEntityData.defineId(
NotchEntity.class, EntityDataSerializers.BYTE);
private static final EntityDataAccessor<Integer> 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<UUID> 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<? extends NotchEntity> 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<UUID> 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<net.minecraft.world.entity.ai.attributes.Attribute> 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);
}
}
}
}
@@ -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;
};
}
}
}
@@ -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<NotchEntity> 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<NotchEntity> 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<NotchEntity> loadedResidents(ServerLevel level) {
List<NotchEntity> residents = new ArrayList<>();
for (Entity entity : level.getAllEntities()) {
if (entity instanceof NotchEntity notch && !notch.isRemoved()) residents.add(notch);
}
return residents;
}
private static Optional<NotchEntity> selectCanonical(List<NotchEntity> 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<SpawnState> 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<SpawnState> 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();
}
}
}
@@ -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<ButterflyEntity> 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<NotchEntity> NOTCH = registerNotch(NOTCH_ID);
/** Hidden alias used only to deserialize Notch entities written by the historical Portals prototype. */
public static final EntityType<NotchEntity> 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<NotchEntity> 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)));
}
}
@@ -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"
}
@@ -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"
}
@@ -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": "Нотч-Титан"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -38,13 +38,13 @@
"fabric-api": ">=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}",
@@ -61,6 +61,22 @@ public final class CanapliaGameplaySmoke {
Set.of(), Temperature.COLD, Humidity.BALANCED, 12, false, false, true, true, false)).isEmpty(),
"lunar harvest requires a sky dimension");
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
assertEquals(variant, placed,
"historical seed placement variant " + variant.key());
CanapliaVariantKey grown = CanapliaVariantLifecycle.afterGrowth(placed);
assertEquals(variant, grown,
"natural growth variant " + variant.key());
CanapliaVariantKey persisted = CanapliaVariantLifecycle.fromPersistedKey(
CanapliaVariantLifecycle.persistedKey(grown));
assertEquals(variant, persisted, "persisted variant " + variant.key());
assertEquals(variant, CanapliaVariantLifecycle.upperFromLower(persisted),
"upper crop variant " + variant.key());
assertEquals(variant, CanapliaVariantLifecycle.harvestedFromPlant(persisted),
"harvest variant " + variant.key());
}
CanapliaEffectRules.Plan minimum = CanapliaEffectRules.plan(0, 1, 0, 0);
assertEquals(CanapliaEffectRules.MIN_PRIMARY_TICKS, minimum.primaryTicks(), "minimum primary duration");
assertTrue(!minimum.badTrip(), "nonzero bad-trip roll");
@@ -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);
}
}
}
@@ -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<CommandSourceStack, ?> 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<CommandSourceStack, ?> ambianceCommand() {
var root = Commands.literal("ambiance")
.executes(context -> ambianceStatus(context.getSource()))
@@ -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",
@@ -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",
@@ -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": "принудительно",
+2 -2
View File
@@ -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": {
@@ -48,7 +48,7 @@ public final class OnlyFunMod implements ModInitializer {
OnlyFunCommands.register();
AbjectionActions.initialize();
AbjectionCommands.register();
LOGGER.info("[{}] Loot generator, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
LOGGER.info("[{}] Loot generator, boss-gated Shop, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, OnlyFunNetworking.PROTOCOL_VERSION);
}
}
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.onlyfun.loot;
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunRegistries;
import fr.koka99cab.sanctuary26.sanctuary.api.entity.SanctuaryEntityPolicy;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
@@ -38,7 +39,8 @@ public final class OnlyFunLootboxDrops {
}
private static void afterDeath(LivingEntity entity, DamageSource source) {
if (!(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
if (SanctuaryEntityPolicy.isTemporaryCompanion(entity)
|| !(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
ServerPlayer killer = playerKiller(entity, source);
if (killer == null || killer.isSpectator()) return;
@@ -44,6 +44,7 @@ public final class OnlyFunItemPolicy {
if (namespace.equals("canaplia") || namespace.equals("itsalive")) return true;
if (namespace.equals("minecraft") && VANILLA_SHOP_GAMEPLAY_BYPASSES.contains(path)) return true;
if (namespace.equals("anotherworld") && isProtectedAnotherWorldItem(path)) return true;
if (isStorageTerminal(namespace, path)) return true;
if (TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
|| path.contains("command_block")) return true;
if (path.contains("lootbox") || path.contains("loot_box")) return true;
@@ -61,6 +62,7 @@ public final class OnlyFunItemPolicy {
String namespace = id.getNamespace().toLowerCase(Locale.ROOT);
String path = normalizedPath(id);
return namespace.equals("canaplia") || path.contains("lootbox") || path.contains("loot_box")
|| isStorageTerminal(namespace, path)
|| (namespace.equals("onlyfun") && path.equals("lucky_block"))
|| (namespace.equals("sanctuary") && path.equals("delivery_box"))
|| TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
@@ -77,6 +79,10 @@ public final class OnlyFunItemPolicy {
|| path.contains("titanium") || path.contains("rainbow_block");
}
private static boolean isStorageTerminal(String namespace, String path) {
return namespace.equals("redstoner") && path.equals("storage_terminal");
}
private static String normalizedPath(Identifier id) {
return id.getPath().toLowerCase(Locale.ROOT);
}
@@ -1,29 +1,22 @@
package fr.koka99cab.sanctuary26.onlyfun.shop;
import java.util.Objects;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import fr.koka99cab.sanctuary26.sanctuary.api.progression.SanctuaryWorldMilestones;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Keeps exceptional boss rewards out of the Shop until their future boss owner unlocks them. */
/** Keeps exceptional boss rewards out of the Shop until their public world milestone is reached. */
public final class OnlyFunBossShopGate {
public static final Identifier MAGIC_CLOUD = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_cloud");
public static final Identifier MAGIC_CARPET = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_carpet");
private static final Set<Identifier> BOSS_REWARDS = Set.of(MAGIC_CLOUD, MAGIC_CARPET);
private static final BossUnlockResolver LOCKED = (server, reward) -> false;
private static volatile BossUnlockResolver resolver = LOCKED;
private static final Map<Identifier, Identifier> IMPLEMENTED_MILESTONES = Map.of(
MAGIC_CARPET, SanctuaryNotchMilestone.NOTCH_DEFEATED);
private OnlyFunBossShopGate() {}
/** Called later by the module that owns the bosses and their persistent defeat state. */
public static synchronized void registerUnlockResolver(BossUnlockResolver value) {
Objects.requireNonNull(value, "value");
if (resolver != LOCKED && resolver != value) {
throw new IllegalStateException("A boss Shop unlock resolver is already registered");
}
resolver = value;
}
public static boolean requiresBoss(String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id != null && BOSS_REWARDS.contains(id);
@@ -31,11 +24,13 @@ public final class OnlyFunBossShopGate {
public static boolean isAvailable(MinecraftServer server, String itemId) {
Identifier id = Identifier.tryParse(itemId);
return id == null || !BOSS_REWARDS.contains(id) || resolver.isUnlocked(server, id);
if (id == null || !BOSS_REWARDS.contains(id)) return true;
Identifier milestone = requiredMilestone(id);
return milestone != null && SanctuaryWorldMilestones.isReached(server, milestone);
}
@FunctionalInterface
public interface BossUnlockResolver {
boolean isUnlocked(MinecraftServer server, Identifier rewardItem);
/** Returns null while the corresponding boss contract is not implemented. */
public static Identifier requiredMilestone(Identifier rewardItem) {
return IMPLEMENTED_MILESTONES.get(rewardItem);
}
}
+2 -2
View File
@@ -26,8 +26,8 @@
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"iliketomoveit": "*",
"sanctuary": "*"
"iliketomoveit": ">=0.0.0-alpha.17",
"sanctuary": ">=0.0.0-alpha.129"
},
"custom": {
"sanctuary26": {
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.onlyfun.shop;
import fr.koka99cab.sanctuary26.onlyfun.policy.OnlyFunItemPolicy;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOfferCatalog;
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -81,18 +82,25 @@ public final class OnlyFunShopCatalogSmoke {
require(!OnlyFunItemPolicy.excludedFromShop(Identifier.parse(rawFoodId)),
"Raw ingredient was removed with finished food: " + rawFoodId);
}
Identifier storageTerminal = Identifier.parse("redstoner:storage_terminal");
require(OnlyFunItemPolicy.excludedFromShop(storageTerminal),
"Storage Terminal reached the Shop policy");
require(OnlyFunItemPolicy.excludedFromRewards(storageTerminal),
"Storage Terminal reached lootbox rewards");
require(find(all, storageTerminal.toString()) == null,
"Storage Terminal reached the generated Shop catalogue");
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_carpet"),
"Magic Carpet must remain boss-gated");
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_cloud"),
"Magic Cloud must remain boss-gated");
require(!OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Boss rewards must be unavailable while their bosses do not exist");
OnlyFunBossShopGate.registerUnlockResolver((server, reward) ->
reward.equals(OnlyFunBossShopGate.MAGIC_CARPET));
require(OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
"Each future boss must unlock only its corresponding reward");
"Boss rewards must reject lookups without authoritative server state");
require(SanctuaryNotchMilestone.NOTCH_DEFEATED.equals(
OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CARPET)),
"Magic Carpet must resolve the public Notch milestone");
require(OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CLOUD) == null,
"Magic Cloud must stay locked until its own boss is implemented");
for (String protectedId : List.of(
"anotherworld:ruby", "anotherworld:ruby_block", "anotherworld:deepslate_ruby_ore",
+10 -1
View File
@@ -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 longlet créatif
@@ -79,6 +79,15 @@ najoutent ni payload réseau, ni donnée persistante, ni munition enregistré
LArc bêta réutilise volontairement lapparence 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
+1 -1
View File
@@ -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"]
@@ -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);
@@ -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<? extends NotchFireballEntity> 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);
}
}
}
@@ -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<EntityType<?>> FIREBALL_KEY = ResourceKey.create(
Registries.ENTITY_TYPE, FIREBALL_ID);
public static final EntityType<NotchFireballEntity> FIREBALL = Registry.register(
BuiltInRegistries.ENTITY_TYPE, FIREBALL_ID,
EntityType.Builder.<NotchFireballEntity>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);
}
}
@@ -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);
}
+2 -1
View File
@@ -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": {
@@ -0,0 +1,64 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.210",
"modules": {
"ambiance": "0.0.0-alpha.14",
"itsalive": "0.0.0-alpha.35",
"onlyfun": "0.0.0-alpha.21",
"ouch": "0.0.0-alpha.9",
"sanctuary": "0.0.0-alpha.118"
}
},
"target": {
"pack_version": "26.2.0-alpha.211",
"modules": {
"ambiance": "0.0.0-alpha.15",
"itsalive": "0.0.0-alpha.36",
"onlyfun": "0.0.0-alpha.24",
"ouch": "0.0.0-alpha.10",
"sanctuary": "0.0.0-alpha.119"
}
},
"features": {
"canaplia_codex_uses_vanilla_book_pages": true,
"black_market_middle_click_restored": true,
"white_disc_youtube_audio": true,
"white_disc_max_concurrent_streams": 4,
"abjection_server_actions": ["piss", "vomit", "shit", "smoke"],
"abjection_shit_ball_projectile": true,
"abjection_shit_cube": true,
"abjection_default_key": "J",
"abjection_projectile_nausea_seconds": 7
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_added": [
"ambiance:white_disc",
"abjection:shit_ball",
"abjection:shit_cube"
],
"data_versions": {
"ambiance": 2,
"itsalive": 3,
"onlyfun": 2,
"ouch": 1,
"sanctuary": 11
},
"network_protocols": {
"ambiance": 6,
"itsalive": 4,
"onlyfun": 3,
"ouch": 1,
"sanctuary": 22
}
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,34 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.211",
"modules": {
"sanctuary": "0.0.0-alpha.119"
}
},
"target": {
"pack_version": "26.2.0-alpha.212",
"modules": {
"sanctuary": "0.0.0-alpha.120"
}
},
"features": {
"black_market_middle_click_preserved": true,
"minecraft_mixin_target": "pickBlockOrEntity",
"client_startup_crash_fixed": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_changed": false,
"sanctuary_data_version": 11,
"sanctuary_network_protocol": 22
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,35 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.212",
"modules": {
"ambiance": "0.0.0-alpha.15"
}
},
"target": {
"pack_version": "26.2.0-alpha.213",
"modules": {
"ambiance": "0.0.0-alpha.16"
}
},
"features": {
"white_disc_stop_event_preserved": true,
"minecraft_mixin_target_class": "LevelEventHandler",
"minecraft_mixin_target_method": "levelEvent",
"client_startup_crash_fixed": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_changed": false,
"ambiance_data_version": 2,
"ambiance_network_protocol": 6
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,36 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.213",
"modules": {
"ambiance": "0.0.0-alpha.16"
}
},
"target": {
"pack_version": "26.2.0-alpha.214",
"modules": {
"ambiance": "0.0.0-alpha.17"
}
},
"features": {
"white_disc_solo_playback_restored": true,
"client_jukebox_sync_wait_ticks": 100,
"loading_feedback_immediate": true,
"sync_timeout_feedback": true,
"youtube_share_parameters_supported": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_changed": false,
"ambiance_data_version": 2,
"ambiance_network_protocol": 6
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}

Some files were not shown because too many files have changed in this diff Show More