Compare commits

..
Author SHA1 Message Date
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
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
koka 14980c993f Merge branch 'main' into feature/abjection-keybind
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:49:57 +02:00
koka e17433f28f Merge pull request 'feat(ouch): restaurer le projectile Abjection' (#77) from feature/abjection-projectile into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #77
2026-08-29 16:41:13 +00:00
koka 0833d4e44f Merge branch 'main' into feature/abjection-projectile
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:40:16 +02:00
koka 07b2eba5c5 Merge pull request 'feat(onlyfun): intégrer les actions Abjection' (#76) from feature/abjection-actions into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #76
2026-08-29 16:36:59 +00:00
koka 0f95a5cfc7 Merge branch 'main' into feature/abjection-actions
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:35:00 +02:00
koka 028d63b64a Merge pull request 'feat(ambiance): ajouter le disque blanc YouTube (#62)' (#75) from feature/white-disc-youtube into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #75
2026-08-29 16:30:44 +00:00
koka 61ba842206 Merge branch 'main' into feature/white-disc-youtube
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:29:42 +02:00
koka 0476a74d43 Merge pull request 'feat(sanctuary): rétablir le Black Market au clic molette (#68)' (#74) from feature/black-market-middle-click into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #74
2026-08-29 16:17:00 +00:00
koka d5fe55ddcc Merge branch 'main' into feature/black-market-middle-click
Verification 26.2 / check (pull_request) Canceled after 0s
# Conflicts:
#	CHANGELOG.md
#	build.gradle
2026-08-29 18:12:40 +02:00
koka 4a9f50e488 Merge pull request 'fix(canaplia): restaurer les pages vanilla du Livre interdit (#72)' (#73) from fix/canaplia-normal-pages into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #73
2026-08-29 16:08:49 +00:00
koka b9aabae914 fix(canaplia): restaurer les pages vanilla
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 18:07:31 +02:00
koka b121606b4e Merge pull request 'release: Sanctuary 26.2.0-alpha.210 — Livre interdit et correctifs' (#71) from release/26.2.0-alpha.210 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #71
2026-08-29 15:44:14 +00:00
koka fa40548db7 release: prepare 26.2.0-alpha.210
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 17:39:01 +02:00
koka 7ee1e5f094 fix(ambiance): reprendre la texture vanilla du disque blanc 2026-08-29 17:09:48 +02:00
koka 91b4def490 feat(onlyfun): ajouter la touche Abjection 2026-08-29 15:21:38 +02:00
koka 122ff939aa feat(ouch): restaurer le projectile Abjection 2026-08-29 15:17:37 +02:00
koka 3fef2d5437 feat(onlyfun): importer les actions Abjection 2026-08-29 15:10:14 +02:00
koka 75ddc5b682 feat(ambiance): ajouter le disque blanc YouTube 2026-08-29 14:59:38 +02:00
koka bf09bdfa78 feat(sanctuary): ouvrir le Black Market au clic molette 2026-08-29 14:31:20 +02:00
koka 2df4f35e48 Merge pull request 'feat(itsalive): transformer le Codex en Livre interdit (#65)' (#70) from feature/canaplia-forbidden-book into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #70
2026-08-29 12:19:25 +00:00
koka 4b4881d922 feat(itsalive): transformer le Codex en Livre interdit
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 14:16:34 +02:00
koka 66cd185983 Merge pull request 'feat(iliketomoveit): accélérer le Happy Ghast monté (#61)' (#69) from feature/happy-ghast-cruise-speed into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #69
2026-08-29 12:02:52 +00:00
koka bd93dc1cd6 feat(iliketomoveit): accélérer le Happy Ghast monté
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 13:17:50 +02:00
koka 2838bf0767 Merge pull request 'fix(sanctuary): réserver les requêtes aux panneaux dédiés (#25)' (#67) from fix/request-sign-block-only into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #67
2026-08-29 11:11:41 +00:00
koka e6740ff891 fix(sanctuary): réserver les requêtes aux panneaux dédiés
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:59:08 +02:00
koka 054c8e4ad4 Merge pull request 'fix(sanctuary): resynchroniser les capes à la reconnexion (#13)' (#66) from fix/cape-reconnect-sync into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #66
2026-08-29 10:54:41 +00:00
koka 4b11d217c2 fix(sanctuary): resynchroniser les capes à la reconnexion
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:43:32 +02:00
koka 3923b8547c Merge pull request 'feat(sanctuary): nommer tous les zombies façon Xbox 360 (#33)' (#64) from feature/zombie-xbox-gamertags into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #64
2026-08-29 10:37:23 +00:00
koka 76915e2f44 feat(sanctuary): nommer les zombies façon Xbox 360
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:30:11 +02:00
koka 5aaaf3831c Merge pull request 'fix(onlyfun): retirer les raccourcis de gameplay du Shop (#34)' (#63) from fix/shop-gameplay-bypasses into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #63
2026-08-29 10:27:03 +00:00
koka 37beda56ec fix(onlyfun): retirer les raccourcis du Shop
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:13:15 +02:00
koka 5d48917cf4 Merge pull request 'fix(sanctuary): séparer les statuts du Shop (#32)' (#60) from fix/shop-timer-overlap into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #60
2026-08-29 10:09:04 +00:00
koka 18cca8808b fix(sanctuary): séparer les statuts du Shop
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 11:08:47 +02:00
koka cbba8dc2d3 Merge pull request 'fix(canaplia): restaurer le gameplay et les textures historiques' (#56) from fix/canaplia-gameplay-visuals into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #56
2026-08-29 07:51:38 +00:00
koka 59239b1721 fix(canaplia): restore gameplay and authored visuals
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 09:24:09 +02:00
koka 680b6d647a Merge pull request 'release: Sanctuary 26.2.0-alpha.208 — Canaplia' (#55) from release/26.2.0-alpha.208 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #55
2026-08-29 06:42:27 +00:00
koka dd993d7064 release: prepare 26.2.0-alpha.208 with Canaplia
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 08:26:35 +02:00
koka 1ce299f88a Merge pull request 'feat(canaplia): implémenter le gameplay complet (#36 à #43)' (#54) from integration/canaplia-complete into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #54
2026-08-29 06:09:24 +00:00
428 changed files with 8466 additions and 484 deletions
+8 -6
View File
@@ -18,7 +18,7 @@ It's Alive fournit l'alias Fabric canaplia et dépend explicitement d'Another Wo
Les seize clés stables sont common, tender, olive, dark, ashen, moss, gold, amber, bluish, pink, coral, purple, indigo, varieg, lunar et ember.
Les trois blocs historiques, le type de block entity et les 64 items historiques sont enregistrés une seule fois. Les cultures partagent une croissance à huit âges et conservent leur variante dans le blockstate. Dix têtes identiques dans un composteur produisent le bloc de résine correspondant ; une autre variante est refusée. La résine émet une aura serveur pendant 90 secondes, persiste son temps restant puis disparaît.
Les trois blocs historiques, le type de block entity et les 64 items historiques sont enregistrés une seule fois. Les cultures partagent une croissance à huit âges et conservent leur variante dans le blockstate. Dix têtes identiques dans un composteur produisent le bloc de résine correspondant ; une autre variante est refusée. La résine est posée éteinte, s'allume au briquet, à la charge de feu ou avec un projectile enflammé, émet une fumée visible et applique son aura serveur pendant 90 secondes avant de disparaître.
Les sources sauvages sont chargées depuis data/canaplia/canaplia_discovery/sources.json. Elles fonctionnent lors de la casse de flore dans les chunks existants. Ember est limitée à la flore native du Nether, Indigo aux chorus de l'End, et toutes les variantes Canaplia sont exclues du Shop et des récompenses ordinaires.
@@ -32,9 +32,11 @@ Ambiance possède les trois payloads historiques canaplia:hemp_visual_sync, cana
## Codex
L'item partageable canaplia:codex se fabrique avec un livre et un colorant vert. Son écran distingue les découvertes personnelles des silhouettes connues par la communauté. Les origines génériques et les conditions détaillées se révèlent progressivement sans envoyer de payload de découverte client-vers-serveur.
Les monstres tués par un joueur peuvent laisser une `canaplia:codex_page` et, très rarement, le `canaplia:codex` complet. Quatre pages récupérées permettent aussi d'assembler le Livre interdit autour d'un livre vanilla. Un clic droit sur une page découvre au hasard une entrée encore inconnue et ne consomme la page qu'en cas de nouvelle découverte.
Les données canaplia:codex sont versionnées. Les clés futures syntaxiquement valides restent conservées lors d'un aller-retour afin qu'une migration ne perde pas les découvertes ajoutées par une version ultérieure.
L'ID historique `canaplia:codex` est conservé, mais l'objet s'affiche désormais comme le « Livre interdit » : il utilise la couleur du livre enchanté, ouvre directement les seize entrées et les présente sur les pages claires du livre vanilla. En main, son contenu est strictement personnel. Posé sur un pupitre, il est temporairement inscrit au nom du joueur qui le pose et partage ses découvertes avec tous les lecteurs ; le récupérer en s'accroupissant ou casser le pupitre efface cette inscription.
Les données `canaplia:codex` restent en version 1. Les clés futures syntaxiquement valides et l'ancien champ partagé restent conservés lors d'un aller-retour afin qu'une migration ne perde aucune découverte, mais les nouvelles découvertes ne sont plus publiées globalement. Aucun payload de découverte n'est accepté du client vers le serveur et le protocole It's Alive reste en version 4.
## Tickets livrés
@@ -51,8 +53,8 @@ Les données canaplia:codex sont versionnées. Les clés futures syntaxiquement
## Compatibilité, licence et migrations
Les IDs historiques, les clés et le namespace sont inchangés. L'ancien format non versionné de canaplia:hemp_bud_composters reste lisible et est réécrit sous une enveloppe v1. Le block entity de résine conserve remaining_burn_ticks. Le Codex utilise sa propre donnée v1. Les protocoles deviennent It's Alive v4 et Ambiance v5.
Les IDs historiques, les clés et le namespace sont inchangés. L'ancien format non versionné de canaplia:hemp_bud_composters reste lisible et est réécrit sous une enveloppe v1. Le block entity de résine conserve `remaining_burn_ticks` et migre sa donnée v1 déjà active vers l'état allumé v2. Le Codex utilise sa propre donnée v1. Les protocoles restent It's Alive v4 et Ambiance v5.
La source historique 2.0.0 demeure **All Rights Reserved**, copyright KOKA99CAB. Aucun fichier Java, modèle, texture, son ou JSON historique n'a été copié. Le code et les palettes 16×16 de cette implémentation sont originaux et restent sous GPL-3.0-or-later.
La source historique 2.0.0 demeure **All Rights Reserved**, copyright KOKA99CAB. À la demande explicite du détenteur des droits, ses 224 textures 16×16 ont été restaurées dans l'alpha.209 ; leur provenance est documentée avec les ressources. Aucun fichier Java, modèle, son ou JSON historique n'est copié. Le code d'intégration reste sous GPL-3.0-or-later.
Les inventaires machine sont dans pack/migrations/canaplia-foundation-alpha32.json et pack/migrations/canaplia-gameplay-alpha33.json. Le pack distribué reste en 26.2.0-alpha.207 jusqu'à une release dédiée.
Les inventaires machine sont dans `pack/migrations/canaplia-foundation-alpha32.json` et `pack/migrations/canaplia-gameplay-alpha33.json`. La release `26.2.0-alpha.209` décrit la correction de gameplay et l'import autorisé dans `pack/migrations/26.2.0-alpha.208-canaplia-fixes-to-alpha.209.json`; lalpha.210 décrit le Livre interdit et les autres changements distribués dans `pack/migrations/26.2.0-alpha.209-forbidden-book-and-polish-to-alpha.210.json`.
+138 -2
View File
@@ -6,9 +6,143 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
## En développement — Canaplia complet
## `26.2.0-alpha.219`
Modules modifiés : **It's Alive `0.0.0-alpha.33`**, **Ambiance `0.0.0-alpha.14`** et **Only Fun `0.0.0-alpha.20`**. Le pack distribué reste en **`26.2.0-alpha.207`** jusqu'à la prochaine release.
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.
- rétablit l'accès au Black Market par clic molette sur un panneau, avec une intention vide et une nouvelle validation complète de la cible côté serveur ;
- conserve les données Sanctuary v11 et porte son protocole réseau de 21 à 22, sans nouvel ID persistant.
- ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de linsérer dans un jukebox ;
- valide strictement lhôte et lidentifiant vidéo côté serveur, ne transmet quun identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ;
- diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6.
- importe le cœur GPL historique dAbjection dans Only Fun, sans onzième JAR : `/piss`, `/vomit`, `/shit` et `/smoke` retrouvent leurs durées, jets, impacts et messages ;
- restaure `abjection:shit_ball` ainsi que le bloc boueux `abjection:shit_cube`, sa recette, son butin et ses ressources originales en FR/EN/RU ;
- expose une API serveur unique pour les commandes et le futur raccourci, nettoie les actions temporaires à la mort, en spectateur ou à la déconnexion, et conserve les données v2 ainsi que le protocole réseau 2 dOnly Fun.
- rend `abjection:shit_ball` lançable à la main et par dispenser grâce à une fabrique publique installée par Ouch, sans réenregistrer lobjet possédé par Only Fun ;
- restaure le projectile historique de 0,25 bloc, sa consommation unique hors créatif, son rendu dobjet, son impact boueux et ses sept secondes de nausée ajoutées à la durée existante ;
- déclare la dépendance sans cycle `ouch -> onlyfun` ; les données et protocoles réseau dOuch et dOnly Fun restent inchangés.
- 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`
Modules modifiés : **Sanctuary `0.0.0-alpha.118`**, **Only Fun `0.0.0-alpha.21`**, **I Like To Move It `0.0.0-alpha.15`** et **It's Alive `0.0.0-alpha.35`**. Les six autres modules conservent leur version issue de lalpha.209.
- sépare le compteur quotidien et le statut de déblocage du Shop pour empêcher leur chevauchement ;
- retire du Shop Only Fun les raccourcis qui contournaient la progression normale ;
- attribue à tous les Zombies vanilla des gamertags déterministes inspirés de la Xbox 360 ;
- resynchronise les capes après une reconnexion avec des tentatives retardées et bornées ;
- réserve les requêtes communautaires aux panneaux dédiés au lieu dintercepter les panneaux ordinaires ;
- double la vitesse de croisière du Happy Ghast lorsquun joueur le pilote, sans accélérer ses déplacements autonomes ;
- conserve les données v3 et le protocole réseau 5, sans migration de sauvegarde ni nouvel ID.
- transforme le Codex Canaplia en « Livre interdit » aux pages noires, sans page d'accueil, et fait découvrir une entrée aléatoire encore inconnue à chaque page consommée ;
- garde les découvertes du Livre interdit personnelles en main et partage temporairement celles du propriétaire uniquement lorsque le livre est posé sur un pupitre ;
- conserve les IDs et les anciennes données partagées, ainsi que les données v3 et le protocole réseau 4 d'It's Alive, sans migration de sauvegarde.
- actualise JEI de `30.28.0.191` à la bêta compatible `30.28.0.193` dans Packwiz et le manifeste Prism/Modrinth ;
- distribue les quatre JAR mis à jour dans Packwiz, Prism et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde.
## `26.2.0-alpha.209`
Module modifié : **It's Alive `0.0.0-alpha.34`**. Les neuf autres modules conservent leur version issue de lalpha.208.
- restaure les 224 textures 16×16 authored du Canaplia historique, avec provenance et autorisation explicite du détenteur des droits, à la place des palettes procédurales illisibles ;
- rétablit la consommation des seize joints avec animation, son, particules de fumée, découverte et effets serveur ;
- pose les blocs de résine éteints, permet de les allumer au briquet, à la charge de feu ou avec un projectile enflammé, puis affiche une fumée de feu de camp pendant leur aura de 90 secondes ;
- migre sans perte les résines déjà posées par lalpha.208 vers l’état allumé et porte les données It's Alive en v3 sans changer le protocole réseau 4 ;
- remplace lancien panneau vert du Codex par l’écran et les pages du livre Minecraft ;
- ajoute les pages de Canaplia aux butins des monstres tués par un joueur, un rare butin direct du Codex et une recette livre + quatre pages ;
- distribue It's Alive `0.0.0-alpha.34` dans les manifestes Packwiz, Prism et Modrinth du pack alpha.209, sans exiger de nouveau monde.
## `26.2.0-alpha.208`
Modules modifiés : **It's Alive `0.0.0-alpha.33`**, **Ambiance `0.0.0-alpha.14`** et **Only Fun `0.0.0-alpha.20`**. Les sept autres modules conservent leur version issue de lalpha.207.
- enregistre sous les IDs historiques les seize cultures Canaplia, leurs graines, têtes, résines et joints, avec ressources originales 16×16 et traductions FR/EN/RU ;
- transforme de façon déterministe dix têtes identiques en résine correspondante, migre l'ancien suivi de composteur et fait persister l'aura de résine pendant 90 secondes ;
@@ -18,6 +152,8 @@ Modules modifiés : **It's Alive `0.0.0-alpha.33`**, **Ambiance `0.0.0-alpha.14
- fait composer par Ambiance un seul profil normal et un seul bad trip, sans shader concurrent, avec intensité, réduction des mouvements, wobble et ghosting configurables depuis les contrôles ;
- ajoute un Codex partageable qui conserve les découvertes personnelles et communautaires, révèle ses indices progressivement et préserve les futures clés inconnues ;
- exclut tout le namespace Canaplia du Shop et des lootboxes ordinaires, conserve les IDs historiques et ne copie aucun fichier de la source 2.0.0 All Rights Reserved.
- distribue les trois JAR mis à jour dans Packwiz, Prism et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde.
- actualise JEI de `30.27.0.189` à la bêta compatible `30.28.0.191` dans les manifestes Packwiz et Modrinth.
## `26.2.0-alpha.207`
+13 -5
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.155.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.158.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -11,8 +11,8 @@ 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, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
| `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, 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,9 +22,17 @@ 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.207`**. Cette release distribue Another World **`0.0.0-alpha.55`** avec les soldes 64 bits pratiquement illimités du coffre-fort.
Version courante du pack distribué : **`26.2.0-alpha.219`**. Cette release distribue 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.
Dans le workspace en développement, [CANA-01 à CANA-09](CANAPLIA.md) portent Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.33`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Codex. 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.20`** exclut explicitement Canaplia du Shop et des récompenses ordinaires. Le pack distribué reste en **`26.2.0-alpha.207`** jusqu'à la prochaine release.
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.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 corrige définitivement la hauteur trop grave du disque blanc : la cadence AAC mesurée sur la vidéo signalée est compensée par un facteur OpenAL exact de 4/3, maintenu pendant toute la lecture.
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.
+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.
+33
View File
@@ -0,0 +1,33 @@
# Disque blanc YouTube
Le disque blanc appartient à **Ambiance**, propriétaire de laudio positionnel. Il conserve lID
`ambiance:white_disc` et se fabrique avec un disque 13 entouré de huit colorants blancs. Pour le
lire, il faut le renommer dans une enclume avec une URL YouTube HTTPS puis linsérer dans un
jukebox vanilla.
## Contrat réseau et sécurité
- le serveur reste lautorité de linsertion et refuse les noms qui ne correspondent pas à une URL
HTTPS exacte de `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com` ou
`youtu.be` ;
- les formats `watch`, `shorts`, `embed`, `live` et le lien court sont acceptés, mais lidentifiant
doit contenir exactement onze caractères ASCII autorisés ;
- le payload S2C ne contient que la position du jukebox et cet identifiant borné, jamais une URL
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.
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 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.
+191 -3
View File
@@ -1,8 +1,51 @@
import groovy.json.JsonSlurper
def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"
configurations {
whiteDiscLibraries
implementation.extendsFrom whiteDiscLibraries
include.extendsFrom whiteDiscLibraries
}
dependencies {
implementation project(':sanctuary')
compileOnly "maven.modrinth:YL57xq9U:oaD6KQls"
whiteDiscLibraries("dev.arbjerg:lavaplayer:2.2.7") {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
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:${youtubeSourceVersion}",
"org.mozilla:rhino-engine:1.7.15",
"org.mozilla:rhino:1.7.15",
"commons-io:commons-io:2.13.0",
"org.jsoup:jsoup:1.16.1",
"net.iharder:base64:2.3.9",
"org.json:json:20240303",
"org.apache.httpcomponents:httpclient:4.5.14",
"org.apache.httpcomponents:httpcore:4.4.16",
"commons-logging:commons-logging:1.2",
"commons-codec:commons-codec:1.11",
"com.fasterxml.jackson.core:jackson-core:2.15.2",
"com.fasterxml.jackson.core:jackson-databind:2.15.2",
"com.fasterxml.jackson.core:jackson-annotations:2.15.2",
"com.grack:nanojson:1.7"
].each { coordinates ->
whiteDiscLibraries(coordinates) { transitive = false }
}
}
tasks.named("jar") {
from("THIRD_PARTY_NOTICES.md") {
rename { "THIRD_PARTY_NOTICES_ambiance.md" }
}
}
tasks.register("ambianceVisualApiSmoke", JavaExec) {
@@ -21,15 +64,49 @@ tasks.register("ambianceScheduleSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.ambiance.environment.AmbianceScheduleSmoke"
}
tasks.register("whiteDiscUrlSmoke", JavaExec) {
group = "verification"
description = "Checks strict and bounded YouTube URL parsing for the white disc."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
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.12, including timed weather overrides, playable night fog and weather/lunar controls."
description = "Checks Ambiance alpha.21, including corrected white-disc pitch 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"))
@@ -68,8 +145,23 @@ tasks.register("verifyAmbiance") {
def languages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
[(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))]
}
def ambianceLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
[(language): new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${language}.json"))]
}
def whiteDiscTexture = javax.imageio.ImageIO.read(
file("src/main/resources/assets/ambiance/textures/item/white_disc.png"))
def whiteDiscRecipe = new JsonSlurper().parse(
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.14"
if (project.version.toString() != "0.0.0-alpha.21"
|| 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"
@@ -84,7 +176,7 @@ tasks.register("verifyAmbiance") {
|| migration.compatibility?.ambiance_data_version != 1
|| migration.compatibility?.ambiance_network_protocol != 2
|| manifest.custom?.sanctuary26?.data_version != 2
|| manifest.custom?.sanctuary26?.network_protocol != 5
|| manifest.custom?.sanctuary26?.network_protocol != 6
|| dailyMigration.target?.pack_version != "26.2.0-alpha.128"
|| dailyMigration.target?.modules?.ambiance != "0.0.0-alpha.2"
|| dailyMigration.lunar_schedule?.cycle != "minecraft_native_eight_days"
@@ -262,6 +354,57 @@ tasks.register("verifyAmbiance") {
|| !javaText.contains("Mth.lerp(night, 1.0F, 0.98F)")) {
throw new GradleException("Ambiance alpha.11 playable-night fog contract is incomplete")
}
if (!javaText.contains('id("white_disc_playback")')
|| !javaText.contains('readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)')
|| !javaText.contains('PlayerLookup.tracking(level, pos)')
|| !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)')
|| !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4')
|| !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 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 = 4.0F / 3.0F')
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|| !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(')
|| !javaText.contains('"https".equalsIgnoreCase(uri.getScheme())')
|| !file("src/main/resources/assets/ambiance/items/white_disc.json").isFile()
|| !file("src/main/resources/assets/ambiance/models/item/white_disc.json").isFile()
|| whiteDiscTexture == null || whiteDiscTexture.width != 16 || whiteDiscTexture.height != 16
|| whiteDiscRecipe.result?.id != "ambiance:white_disc"
|| 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 {
translations.containsKey(it)
}
}
|| !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7')
|| !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")
}
[
'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology',
@@ -309,8 +452,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.21"
|| 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")
}
@@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceBloodNightAssault;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceWeatherCommands;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceNetworking;
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscService;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
@@ -29,6 +30,7 @@ public final class AmbianceMod implements ModInitializer {
public void onInitialize() {
AmbianceRegistries.initialize();
AmbianceNetworking.initialize();
WhiteDiscService.initialize();
RealtimeMod.initialize();
AmbianceEnvironmentController.initialize();
AmbianceFirstSpawnVision.initialize();
@@ -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");
}
}
}
}
@@ -4,7 +4,10 @@ import fr.koka99cab.realtime.client.RealtimeClient;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceEnvironmentPayload;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
@@ -30,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) ->
@@ -41,11 +45,15 @@ public final class AmbianceClient implements ClientModInitializer {
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Clear.ID, (payload, context) ->
context.client().execute(AmbianceClientVisualEffects::clear));
ClientPlayNetworking.registerGlobalReceiver(WhiteDiscPlaybackPayload.ID, (payload, context) ->
context.client().execute(() -> WhiteDiscAudioPlayer.play(
context.client(), payload.pos(), payload.videoId())));
visualSettingsKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.ambiance.visual_settings", InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_UNKNOWN, AMBIANCE_CATEGORY));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
AmbianceClientVisualEffects.tick();
WhiteDiscAudioPlayer.tick(client);
while (visualSettingsKey.consumeClick()) {
if (client.gui.screen() == null) client.gui.setScreen(new AmbianceVisualSettingsScreen(null));
}
@@ -54,7 +62,9 @@ public final class AmbianceClient implements ClientModInitializer {
AmbianceClientEnvironment.reset();
DynamicTorchLights.reset();
AmbianceClientVisualEffects.clear();
WhiteDiscAudioPlayer.clear();
});
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown());
AmbianceVisualOverlay.register();
AmbianceBiomeTints.registerColorResolvers();
registerWaterFluidTint();
@@ -0,0 +1,120 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
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. */
final class LavaplayerAudioStream implements AudioStream {
static final int SAMPLE_RATE = 48_000;
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;
LavaplayerAudioStream(AudioPlayer player) {
this.player = player;
}
@Override
public AudioFormat getFormat() {
return FORMAT;
}
@Override
public ByteBuffer read(int maximumBytes) throws IOException {
if (closed || maximumBytes <= 0) return null;
ByteBuffer output = ByteBuffer.allocateDirect(maximumBytes);
while (output.hasRemaining() && !closed) {
if (pendingOffset < pending.length) {
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;
while (output.hasRemaining()) {
output.put((byte) 0);
paddedSilenceBytes++;
}
break;
}
if (frame.isTerminator()) {
ended = true;
break;
}
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
lastFrameTimecode = frame.getTimecode();
decodedFrames++;
pending = frame.getData();
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
output.flip();
return output;
}
private AudioFrame nextFrame() throws IOException {
try {
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) {
return null;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while buffering the white disc", interrupted);
}
}
boolean ended() {
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;
closed = true;
player.destroy();
}
}
@@ -0,0 +1,304 @@
package fr.koka99cab.sanctuary26.ambiance.client.audio;
import com.mojang.blaze3d.audio.Library;
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;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import net.minecraft.client.Minecraft;
import net.minecraft.client.sounds.ChannelAccess;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.JukeboxBlock;
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;
/** Compensates Lavaplayer's low AAC cadence before PCM reaches OpenAL. */
static final float PLAYBACK_PITCH = 4.0F / 3.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;
private static boolean shutdown;
private WhiteDiscAudioPlayer() {
}
public static void play(Minecraft client, BlockPos pos, String videoId) {
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validJukeboxTarget(client, pos)) return;
long key = pos.asLong();
if (PENDING_STARTS.containsKey(key) || REQUESTS.containsKey(key)) return;
boolean replacingPlayback = PLAYBACKS.containsKey(key);
if (!replacingPlayback
&& 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);
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
client.execute(() -> acceptTrack(client, pos, request, track));
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
AudioTrack selected = playlist.getSelectedTrack();
if (selected == null && !playlist.getTracks().isEmpty()) selected = playlist.getTracks().getFirst();
if (selected == null) noMatches();
else trackLoaded(selected);
}
@Override
public void noMatches() {
client.execute(() -> fail(client, key, request));
}
@Override
public void loadFailed(FriendlyException exception) {
AmbianceMod.LOGGER.warn("Unable to load white-disc YouTube video {}: {}", videoId,
exception.getMessage());
client.execute(() -> fail(client, key, request));
}
});
}
private static void acceptTrack(Minecraft client, BlockPos pos, long request, AudioTrack track) {
long key = pos.asLong();
if (REQUESTS.getOrDefault(key, -1L) != request) return;
if (!validTarget(client, pos)) {
REQUESTS.remove(key);
return;
}
if (track.getInfo().isStream || track.getDuration() <= 0L
|| track.getDuration() > MAXIMUM_TRACK_MILLIS) {
REQUESTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.too_long"));
return;
}
while (PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) {
stop(PLAYBACKS.keySet().iterator().next());
}
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
|| !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();
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 -> {
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));
}));
}
private static void fail(Minecraft client, long key, long request) {
if (REQUESTS.getOrDefault(key, -1L) != request) return;
REQUESTS.remove(key);
if (client.player != null) client.player.sendOverlayMessage(
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());
}
public static void tick(Minecraft client) {
if (client.level == null) {
clear();
return;
}
tickPendingStarts(client);
long now = System.nanoTime();
for (Map.Entry<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
Playback playback = entry.getValue();
boolean missing = !client.level.dimension().equals(playback.dimension())
|| !client.level.hasChunkAt(playback.pos())
|| !client.level.getBlockState(playback.pos()).is(Blocks.JUKEBOX)
|| !client.level.getBlockState(playback.pos()).getValue(JukeboxBlock.HAS_RECORD);
if (missing || playback.finishedFor(now) >= FINISHED_GRACE_NANOS) {
stop(entry.getKey());
continue;
}
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
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);
}
public static void shutdown() {
if (shutdown) return;
shutdown = true;
clear();
MANAGER.shutdown();
}
private static DefaultAudioPlayerManager createManager() {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
manager.setFrameBufferDuration(5_000);
manager.setItemLoaderThreadPoolSize(2);
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);
}
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,
ChannelAccess.ChannelHandle handle,
LavaplayerAudioStream stream, long[] finishedAt) {
private Playback(BlockPos pos, ResourceKey<Level> dimension,
ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) {
this(pos, dimension, handle, stream, new long[1]);
}
private long finishedFor(long now) {
if (!stream.ended()) return 0L;
if (finishedAt[0] == 0L) finishedAt[0] = now;
return now - finishedAt[0];
}
}
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.ambiance.item;
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;
/** Blank disc whose custom name may hold a validated YouTube URL. */
public final class WhiteDiscItem extends Item {
public WhiteDiscItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.ambiance.white_disc")
.withStyle(ChatFormatting.GRAY));
}
}
@@ -0,0 +1,17 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.minecraft.client.renderer.LevelEventHandler;
import net.minecraft.core.BlockPos;
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(LevelEventHandler.class)
public abstract class LevelEventHandlerMixin {
@Inject(method = "levelEvent", at = @At("HEAD"))
private void ambiance$stopWhiteDisc(int type, BlockPos pos, int data, CallbackInfo callback) {
if (type == 1011) WhiteDiscAudioPlayer.stop(pos);
}
}
@@ -0,0 +1,12 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import net.minecraft.client.sounds.ChannelAccess;
import net.minecraft.client.sounds.SoundEngine;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundEngine.class)
public interface SoundEngineAccessor {
@Accessor("channelAccess")
ChannelAccess ambiance$getChannelAccess();
}
@@ -0,0 +1,12 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import net.minecraft.client.sounds.SoundEngine;
import net.minecraft.client.sounds.SoundManager;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundManager.class)
public interface SoundManagerAccessor {
@Accessor("soundEngine")
SoundEngine ambiance$getSoundEngine();
}
@@ -13,7 +13,7 @@ import net.minecraft.server.level.ServerPlayer;
* Networking contract for Ambiance. Payload IDs must use the module namespace.
*/
public final class AmbianceNetworking {
public static final int PROTOCOL_VERSION = 5;
public static final int PROTOCOL_VERSION = 6;
private static boolean initialized;
private AmbianceNetworking() {
@@ -34,6 +34,8 @@ public final class AmbianceNetworking {
AmbianceCanapliaVisualPayloads.BadTrip.ID, AmbianceCanapliaVisualPayloads.BadTrip.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
AmbianceCanapliaVisualPayloads.Clear.ID, AmbianceCanapliaVisualPayloads.Clear.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
WhiteDiscPlaybackPayload.ID, WhiteDiscPlaybackPayload.CODEC);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
send(handler.player, AmbianceEnvironmentController.snapshot(server))
);
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.ambiance.network;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Bounded clientbound intent: a server-resolved jukebox position and an 11-character video ID. */
public record WhiteDiscPlaybackPayload(BlockPos pos, String videoId) implements CustomPacketPayload {
public static final Type<WhiteDiscPlaybackPayload> ID =
new Type<>(AmbianceMod.id("white_disc_playback"));
public static final StreamCodec<RegistryFriendlyByteBuf, WhiteDiscPlaybackPayload> CODEC =
CustomPacketPayload.codec(WhiteDiscPlaybackPayload::write, WhiteDiscPlaybackPayload::new);
public WhiteDiscPlaybackPayload {
if (!WhiteDiscUrl.validVideoId(videoId)) videoId = "";
}
private WhiteDiscPlaybackPayload(RegistryFriendlyByteBuf buffer) {
this(buffer.readBlockPos(), buffer.readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH));
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeBlockPos(pos);
buffer.writeUtf(videoId, WhiteDiscUrl.VIDEO_ID_LENGTH);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return ID;
}
}
@@ -1,12 +1,32 @@
package fr.koka99cab.sanctuary26.ambiance.registry;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.item.WhiteDiscItem;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.minecraft.core.Registry;
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.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.JukeboxSong;
import net.minecraft.world.item.Rarity;
/**
* Single registration entry point for the Ambiance namespace.
* Feature registries must be called from here to keep initialization ordering explicit.
*/
public final class AmbianceRegistries {
public static final ResourceKey<JukeboxSong> WHITE_DISC_SONG = ResourceKey.create(
Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc"));
private static final ResourceKey<Item> WHITE_DISC_KEY = ResourceKey.create(
Registries.ITEM, AmbianceMod.id("white_disc"));
private static final ResourceKey<CreativeModeTab> TOOLS_TAB = ResourceKey.create(
Registries.CREATIVE_MODE_TAB, Identifier.withDefaultNamespace("tools_and_utilities"));
public static final Item WHITE_DISC = Registry.register(BuiltInRegistries.ITEM, WHITE_DISC_KEY,
new WhiteDiscItem(new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON)
.jukeboxPlayable(WHITE_DISC_SONG).setId(WHITE_DISC_KEY)));
private static boolean initialized;
private AmbianceRegistries() {
@@ -17,7 +37,8 @@ public final class AmbianceRegistries {
return;
}
initialized = true;
AmbianceMod.LOGGER.debug("[{}] Empty registry hub initialized.", AmbianceMod.DISPLAY_NAME);
CreativeModeTabEvents.modifyOutputEvent(TOOLS_TAB).register(entries -> entries.accept(WHITE_DISC));
AmbianceMod.LOGGER.debug("[{}] Registry hub initialized with the white disc.",
AmbianceMod.DISPLAY_NAME);
}
}
@@ -0,0 +1,70 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.JukeboxPlayable;
import net.minecraft.world.level.block.JukeboxBlock;
import net.minecraft.world.level.block.entity.JukeboxBlockEntity;
/** Server-authoritative insertion and bounded playback dispatch for the white disc. */
public final class WhiteDiscService {
private static boolean initialized;
private WhiteDiscService() {
}
public static void initialize() {
if (initialized) return;
initialized = true;
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
BlockPos pos = hit.getBlockPos();
if (!(level.getBlockEntity(pos) instanceof JukeboxBlockEntity jukebox)
|| jukebox.getBlockState().getValue(JukeboxBlock.HAS_RECORD)) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
if (!held.is(AmbianceRegistries.WHITE_DISC)) return InteractionResult.PASS;
Optional<String> videoId = videoId(held);
if (videoId.isEmpty()) {
if (!level.isClientSide()) player.sendOverlayMessage(
Component.translatable("message.ambiance.white_disc.invalid_url"));
return InteractionResult.FAIL;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
InteractionResult result = JukeboxPlayable.tryInsertIntoJukebox(level, pos, held, player);
if (result.consumesAction() && level instanceof ServerLevel serverLevel) {
broadcast(serverLevel, pos, videoId.get(), player instanceof ServerPlayer serverPlayer
? serverPlayer : null);
}
return result;
});
}
public static Optional<String> videoId(ItemStack stack) {
Component name = stack.get(DataComponents.CUSTOM_NAME);
return name == null ? Optional.empty() : WhiteDiscUrl.videoId(name.getString());
}
private static void broadcast(ServerLevel level, BlockPos pos, String videoId, ServerPlayer inserter) {
WhiteDiscPlaybackPayload payload = new WhiteDiscPlaybackPayload(pos, videoId);
Set<ServerPlayer> receivers = new LinkedHashSet<>(PlayerLookup.tracking(level, pos));
if (inserter != null) receivers.add(inserter);
for (ServerPlayer receiver : receivers) {
if (ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)) {
ServerPlayNetworking.send(receiver, payload);
}
}
}
}
@@ -0,0 +1,74 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/** Strict, side-effect-free YouTube URL parser used before any client network request. */
public final class WhiteDiscUrl {
public static final int MAXIMUM_URL_LENGTH = 256;
public static final int VIDEO_ID_LENGTH = 11;
private static final Pattern VIDEO_ID = Pattern.compile("[A-Za-z0-9_-]{11}");
private static final Set<String> LONG_HOSTS = Set.of(
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com");
private WhiteDiscUrl() {
}
public static Optional<String> videoId(String value) {
if (value == null || value.isBlank() || value.length() > MAXIMUM_URL_LENGTH) return Optional.empty();
try {
URI uri = new URI(value.strip());
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null
|| (uri.getPort() != -1 && uri.getPort() != 443)) return Optional.empty();
String host = uri.getHost();
if (host == null) return Optional.empty();
host = host.toLowerCase(Locale.ROOT);
if ("youtu.be".equals(host)) return valid(singlePathSegment(uri.getRawPath()));
if (!LONG_HOSTS.contains(host)) return Optional.empty();
String path = uri.getRawPath();
if ("/watch".equals(path)) return valid(queryParameter(uri.getRawQuery(), "v"));
for (String prefix : new String[] {"/shorts/", "/embed/", "/live/"}) {
if (path != null && path.startsWith(prefix)) return valid(singlePathSegment(path.substring(prefix.length())));
}
} catch (URISyntaxException ignored) {
// Invalid user text is rejected without network access.
}
return Optional.empty();
}
public static boolean validVideoId(String value) {
return value != null && VIDEO_ID.matcher(value).matches();
}
public static String canonicalUrl(String videoId) {
if (!validVideoId(videoId)) throw new IllegalArgumentException("Invalid YouTube video ID");
return "https://www.youtube.com/watch?v=" + videoId;
}
private static String singlePathSegment(String path) {
if (path == null || path.isEmpty()) return "";
String segment = path.startsWith("/") ? path.substring(1) : path;
return segment.contains("/") ? "" : segment;
}
private static String queryParameter(String query, String name) {
if (query == null) return "";
String found = null;
for (String parameter : query.split("&", -1)) {
int equals = parameter.indexOf('=');
if (equals <= 0 || !name.equals(parameter.substring(0, equals))) continue;
if (found != null) return "";
found = parameter.substring(equals + 1);
}
return found == null ? "" : found;
}
private static Optional<String> valid(String candidate) {
return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty();
}
}
@@ -11,6 +11,9 @@
"GameRendererMixin",
"LightCoordsUtilMixin",
"LightmapRenderStateExtractorMixin",
"LevelEventHandlerMixin",
"SoundEngineAccessor",
"SoundManagerAccessor",
"SkyRendererMixin"
],
"injectors": {
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "ambiance:item/white_disc"
}
}
@@ -24,5 +24,15 @@
"options.ambiance.visual.wobble": "Camera wobble",
"options.ambiance.visual.ghosting": "Edge ghosting",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Visual accessibility settings"
"key.ambiance.visual_settings": "Visual accessibility settings",
"item.ambiance.white_disc": "White Disc",
"jukebox_song.ambiance.white_disc": "White Disc",
"tooltip.ambiance.white_disc": "Rename it to an HTTPS YouTube URL, then insert it into a jukebox.",
"message.ambiance.white_disc.invalid_url": "Rename the white disc to a valid HTTPS YouTube URL.",
"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.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."
}
@@ -24,5 +24,15 @@
"options.ambiance.visual.wobble": "Oscillation de la caméra",
"options.ambiance.visual.ghosting": "Rémanence sur les bords",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Réglages daccessibilité visuelle"
"key.ambiance.visual_settings": "Réglages daccessibilité visuelle",
"item.ambiance.white_disc": "Disque blanc",
"jukebox_song.ambiance.white_disc": "Disque blanc",
"tooltip.ambiance.white_disc": "Renommez-le avec une URL YouTube HTTPS, puis insérez-le dans un jukebox.",
"message.ambiance.white_disc.invalid_url": "Renommez le disque blanc avec une URL YouTube HTTPS valide.",
"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.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."
}
@@ -24,5 +24,15 @@
"options.ambiance.visual.wobble": "Покачивание камеры",
"options.ambiance.visual.ghosting": "Шлейф по краям",
"key.categories.ambiance.ambiance": "Ambiance",
"key.ambiance.visual_settings": "Настройки визуальной доступности"
"key.ambiance.visual_settings": "Настройки визуальной доступности",
"item.ambiance.white_disc": "Белая пластинка",
"jukebox_song.ambiance.white_disc": "Белая пластинка",
"tooltip.ambiance.white_disc": "Переименуйте её в HTTPS-ссылку YouTube и вставьте в проигрыватель.",
"message.ambiance.white_disc.invalid_url": "Переименуйте белую пластинку в допустимую HTTPS-ссылку YouTube.",
"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.alpha_textures_missing": "Текстуры Alpha недоступны: установите Golden Days Base, затем Golden Days Alpha в resourcepacks и оставьте их выключенными.",
"message.ambiance.alpha_textures_restore_failed": "Не удалось перезагрузить прежний набор ресурсов. Он будет восстановлен при следующем запуске."
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/template_music_disc",
"textures": {
"layer0": "ambiance:item/white_disc"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,8 @@
{
"comparator_output": 15,
"description": {
"translate": "jukebox_song.ambiance.white_disc"
},
"length_in_seconds": 1800.0,
"sound_event": "minecraft:intentionally_empty"
}
@@ -0,0 +1,16 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": [
"WWW",
"WDW",
"WWW"
],
"key": {
"D": "minecraft:music_disc_13",
"W": "minecraft:white_dye"
},
"result": {
"id": "ambiance:white_disc"
}
}
+5 -5
View File
@@ -3,7 +3,7 @@
"id": "ambiance",
"version": "${version}",
"name": "Ambiance",
"description": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.",
"description": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
"authors": [
"KOKA99CAB"
],
@@ -36,13 +36,13 @@
"system_type": "gameplay-system",
"namespace": "ambiance",
"data_version": 2,
"network_protocol": 5,
"network_protocol": 6,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
"en_us": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.",
"ru_ru": "Управляет атмосферой, динамическим освещением, календарём и доступными составными визуальными эффектами, включая Canaplia."
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, l'audio positionnel, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
"en_us": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
"ru_ru": "Управляет атмосферой, динамическим освещением, позиционным звуком, календарём и доступными составными визуальными эффектами, включая Canaplia."
}
}
}
@@ -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,83 @@
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.Arrays;
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 FRAME_BYTES = FRAME_SAMPLES * 2;
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 - 4.0F / 3.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc AAC pitch compensation changed");
}
Queue<AudioFrame> frames = new ArrayDeque<>();
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
1, LavaplayerAudioStream.SAMPLE_RATE, FRAME_SAMPLES, false);
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
byte[] data = new byte[FRAME_BYTES];
Arrays.fill(data, (byte) (index + 1));
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");
}
while (pcm.hasRemaining()) {
if (pcm.get() == 0) {
throw new IllegalStateException("The stream padded available PCM with silence");
}
}
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");
}
}
}
@@ -0,0 +1,109 @@
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(1, LavaplayerAudioStream.SAMPLE_RATE, 960, 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;
}
}
@@ -0,0 +1,42 @@
package fr.koka99cab.sanctuary26.ambiance.white_disc;
/** Executable URL-policy regression test, intentionally independent from a running game. */
public final class WhiteDiscUrlSmoke {
private static final String VIDEO_ID = "dQw4w9WgXcQ";
private 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");
reject("http://www.youtube.com/watch?v=" + VIDEO_ID);
reject("https://youtube.example/watch?v=" + VIDEO_ID);
reject("https://youtube.com.evil.example/watch?v=" + VIDEO_ID);
reject("https://user@www.youtube.com/watch?v=" + VIDEO_ID);
reject("https://www.youtube.com:8443/watch?v=" + VIDEO_ID);
reject("https://www.youtube.com/watch?v=too_short");
reject("https://www.youtube.com/watch?v=" + VIDEO_ID + "&v=" + VIDEO_ID);
reject("https://youtu.be/" + VIDEO_ID + "/extra");
reject("https://www.youtube.com/watch?v=dQw4w9WgX%63Q");
reject("x".repeat(WhiteDiscUrl.MAXIMUM_URL_LENGTH + 1));
}
private static String id(String url) {
return WhiteDiscUrl.videoId(url).orElseThrow();
}
private static void reject(String url) {
require(WhiteDiscUrl.videoId(url).isEmpty(), "Unsafe URL accepted: " + url);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+1280 -132
View File
File diff suppressed because it is too large Load Diff
+8 -8
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.14
ambiance_version=0.0.0-alpha.21
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.10
redstoner_version=0.0.0-alpha.11
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.55
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.33
itsalive_version=0.0.0-alpha.36
itsalive_lifecycle=active
ouch_version=0.0.0-alpha.9
ouch_version=0.0.0-alpha.10
ouch_lifecycle=active
masterkey_version=0.0.0-alpha.8
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.14
iliketomoveit_version=0.0.0-alpha.16
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.20
onlyfun_version=0.0.0-alpha.25
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.117
sanctuary_version=0.0.0-alpha.122
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.207
pack_version=26.2.0-alpha.219
maven_group=fr.koka99cab.sanctuary26
+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.
+67 -8
View File
@@ -16,6 +16,22 @@ tasks.register("waystoneElevatorRulesSmoke", JavaExec) {
dependsOn tasks.named("testClasses")
}
tasks.register("happyGhastCruiseSpeedSmoke", JavaExec) {
group = "verification"
description = "Checks the rider-only Happy Ghast cruise-speed multiplier."
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeedSmoke"
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.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -168,6 +184,7 @@ tasks.register("verifyGrappling") {
"data/iliketomoveit/loot_table/blocks/pulley_head.json",
"data/spiderman/loot_table/blocks/pulley_head.json",
"data/minecraft/tags/block/needs_stone_tool.json",
"iliketomoveit.mixins.json",
"iliketomoveit.client.mixins.json"
]
def missingResources = requiredResources.findAll { !file("src/main/resources/${it}").isFile() }
@@ -184,10 +201,11 @@ tasks.register("verifyGrappling") {
}
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (!manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
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?.network_protocol != 5) {
throw new GradleException("Grappling must keep I Like To Move It aggregate data v3/protocol 5 and register its client mixin")
throw new GradleException("I Like To Move It must keep aggregate data v3/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"))
@@ -258,7 +276,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")
@@ -295,7 +313,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.14"
if (project.version.toString() != "0.0.0-alpha.16"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -303,7 +321,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) {
@@ -331,7 +349,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.14"
if (project.version.toString() != "0.0.0-alpha.16"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -360,7 +378,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.14"
if (project.version.toString() != "0.0.0-alpha.16"
|| 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
@@ -392,7 +410,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.14"
if (project.version.toString() != "0.0.0-alpha.16"
|| 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
@@ -408,13 +426,54 @@ 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.16"
|| 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("waystoneElevatorRulesSmoke")
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
dependsOn tasks.named("verifyWaystones")
dependsOn tasks.named("verifyGrappling")
dependsOn tasks.named("verifyGrapplePrediction")
dependsOn tasks.named("verifyWaystoneDescendingRelease")
dependsOn tasks.named("verifyPortals")
dependsOn tasks.named("verifySilverAircraft")
dependsOn tasks.named("alphaPortalContractSmoke")
dependsOn tasks.named("verifyAlphaPortal")
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeed;
import net.minecraft.world.entity.animal.happyghast.HappyGhast;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
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.CallbackInfoReturnable;
@Mixin(HappyGhast.class)
public abstract class HappyGhastMixin {
@Inject(method = "getRiddenInput", at = @At("RETURN"), cancellable = true)
private void iliketomoveit$boostCruiseSpeed(Player rider, Vec3 input,
CallbackInfoReturnable<Vec3> callback) {
callback.setReturnValue(HappyGhastCruiseSpeed.boost(callback.getReturnValue()));
}
}
@@ -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);
@@ -0,0 +1,15 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import net.minecraft.world.phys.Vec3;
/** Keeps the Happy Ghast ride practical without changing its autonomous movement. */
public final class HappyGhastCruiseSpeed {
public static final double RIDER_INPUT_MULTIPLIER = 2.0D;
private HappyGhastCruiseSpeed() {
}
public static Vec3 boost(Vec3 vanillaInput) {
return vanillaInput.scale(RIDER_INPUT_MULTIPLIER);
}
}
@@ -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"],
@@ -19,6 +19,9 @@
]
},
"mixins": [
{
"config": "iliketomoveit.mixins.json"
},
{
"config": "iliketomoveit.client.mixins.json",
"environment": "client"
@@ -0,0 +1,11 @@
{
"required": true,
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin"
],
"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,27 @@
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
import net.minecraft.world.phys.Vec3;
/** Regression checks for the rider-only Happy Ghast cruise multiplier. */
public final class HappyGhastCruiseSpeedSmoke {
private HappyGhastCruiseSpeedSmoke() {
}
public static void main(String[] args) {
Vec3 vanillaInput = new Vec3(-0.195D, 0.0975D, 0.195D);
Vec3 boosted = HappyGhastCruiseSpeed.boost(vanillaInput);
assertNear(boosted.x, -0.39D, "sideways input");
assertNear(boosted.y, 0.195D, "vertical input");
assertNear(boosted.z, 0.39D, "forward input");
if (!HappyGhastCruiseSpeed.boost(Vec3.ZERO).equals(Vec3.ZERO)) {
throw new IllegalStateException("Idle Happy Ghasts must remain idle");
}
}
private static void assertNear(double actual, double expected, String label) {
if (Math.abs(actual - expected) > 1.0E-12D) {
throw new IllegalStateException(label + ": expected " + expected + ", got " + actual);
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
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.20`
## Version courante — `0.0.0-alpha.36`
## Creepers
+94 -74
View File
@@ -52,19 +52,15 @@ sourceSets.main.resources.srcDir(generatedResources)
sourceSets.main.resources.srcDir(generatedCanapliaResources)
def canapliaVariants = [
[key: "common", color: 0x5F9E55], [key: "tender", color: 0xA7D88B],
[key: "olive", color: 0x8A8C3B], [key: "dark", color: 0x384A42],
[key: "ashen", color: 0xA9B5B5], [key: "moss", color: 0x397B4B],
[key: "gold", color: 0xE4B83E], [key: "amber", color: 0xD57A2E],
[key: "bluish", color: 0x64A8C8], [key: "pink", color: 0xE889A8],
[key: "coral", color: 0xE16E62], [key: "purple", color: 0x8154A8],
[key: "indigo", color: 0x494E9C], [key: "varieg", color: 0xD6E26C],
[key: "lunar", color: 0xC7D8F2], [key: "ember", color: 0xD94B31]
[key: "common"], [key: "tender"], [key: "olive"], [key: "dark"],
[key: "ashen"], [key: "moss"], [key: "gold"], [key: "amber"],
[key: "bluish"], [key: "pink"], [key: "coral"], [key: "purple"],
[key: "indigo"], [key: "varieg"], [key: "lunar"], [key: "ember"]
]
tasks.register("generateCanapliaResources") {
group = "build"
description = "Generates the GPL Canaplia models, loot, recipes and sixteen original palettes."
description = "Generates models, loot and recipes around the restored authored Canaplia sprites."
outputs.dir(generatedCanapliaResources)
doLast {
@@ -75,21 +71,12 @@ tasks.register("generateCanapliaResources") {
target.parentFile.mkdirs()
target.text = JsonOutput.prettyPrint(JsonOutput.toJson(value)) + "\n"
}
def writePng = { String relativePath, Closure painter ->
def target = new File(root, relativePath)
target.parentFile.mkdirs()
def image = new java.awt.image.BufferedImage(16, 16, java.awt.image.BufferedImage.TYPE_INT_ARGB)
painter(image)
ImageIO.write(image, "png", target)
}
def argb = { int rgb -> (0xFF000000L | (rgb & 0xFFFFFF)) as int }
def shade = { int rgb, double factor ->
int r = Math.max(0, Math.min(255, Math.round(((rgb >> 16) & 0xFF) * factor) as int))
int g = Math.max(0, Math.min(255, Math.round(((rgb >> 8) & 0xFF) * factor) as int))
int b = Math.max(0, Math.min(255, Math.round((rgb & 0xFF) * factor) as int))
(r << 16) | (g << 8) | b
}
def path = { String key, String family -> key == "common" ? family : "${key}_${family}" }
def cropTexture = { String key, int age ->
if (age == 7) return key == "common" ? "hemp_tall_bottom" : "hemp_tall_bottom_${key}"
key == "common" ? "hemp_stage_${age}" : "hemp_stage_${age}_${key}"
}
def topTexture = { String key -> key == "common" ? "hemp_tall_top" : "hemp_tall_top_${key}" }
def itemDefinition = { String itemPath, String modelPath ->
writeJson("assets/canaplia/items/${itemPath}.json", [model: [type: "minecraft:model", model: modelPath]])
}
@@ -104,32 +91,13 @@ tasks.register("generateCanapliaResources") {
def resinVariants = [:]
canapliaVariants.each { variant ->
String key = variant.key
int color = variant.color as int
(0..7).each { age ->
cropVariants["age=${age},variant=${key}"] = [model: "canaplia:block/hemp_crop_${key}_stage${age}"]
topVariants["age=${age},variant=${key}"] = [model: "canaplia:block/hemp_top_${key}_stage${Math.max(5, age)}"]
writeJson("assets/canaplia/models/block/hemp_crop_${key}_stage${age}.json", [
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/hemp_crop_${key}_stage${age}"]])
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/${cropTexture(key, age)}"]])
if (age >= 5) writeJson("assets/canaplia/models/block/hemp_top_${key}_stage${age}.json", [
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/hemp_top_${key}_stage${age}"]])
writePng("assets/canaplia/textures/block/hemp_crop_${key}_stage${age}.png") { image ->
int stem = argb(shade(0x4E8A47, 0.72 + age * 0.045))
int leaf = argb(shade(0x69A85C, 0.72 + age * 0.04))
int top = 15 - Math.max(2, age + 3)
for (int y = top; y < 16; y++) { image.setRGB(7, y, stem); image.setRGB(8, y, stem) }
if (age >= 2) { image.setRGB(5, 12, leaf); image.setRGB(6, 11, leaf); image.setRGB(9, 10, leaf); image.setRGB(10, 11, leaf) }
if (age >= 4) { image.setRGB(4, 9, leaf); image.setRGB(5, 10, leaf); image.setRGB(10, 8, leaf); image.setRGB(11, 9, leaf) }
if (age >= 6) { image.setRGB(6, top, argb(shade(color, 0.78))); image.setRGB(7, top - 1, argb(color)); image.setRGB(8, top - 1, argb(shade(color, 1.12))); image.setRGB(9, top, argb(shade(color, 0.82))) }
}
if (age >= 5) writePng("assets/canaplia/textures/block/hemp_top_${key}_stage${age}.png") { image ->
int stem = argb(0x4E8A47)
for (int y = 7; y < 16; y++) { image.setRGB(7, y, stem); image.setRGB(8, y, stem) }
image.setRGB(5, 10, argb(0x69A85C)); image.setRGB(6, 9, argb(0x69A85C)); image.setRGB(9, 8, argb(0x69A85C)); image.setRGB(10, 9, argb(0x69A85C))
int radius = age == 7 ? 2 : 1
for (int y = 2; y <= 4 + radius; y++) for (int x = 7 - radius; x <= 8 + radius; x++) {
if (Math.abs(x - 7.5) + Math.abs(y - 4.0) <= radius + 1.5) image.setRGB(x, y, argb(shade(color, 0.82 + (x + y) % 3 * 0.12)))
}
}
parent: "minecraft:block/crop", textures: [crop: "canaplia:block/${topTexture(key)}"]])
}
String seed = path(key, "hemp_seeds")
@@ -140,25 +108,9 @@ tasks.register("generateCanapliaResources") {
writeJson("assets/canaplia/models/item/${resin}.json", [parent: "canaplia:block/resin_block_${key}"])
itemDefinition(resin, "canaplia:item/${resin}")
writeJson("assets/canaplia/models/block/resin_block_${key}.json", [
parent: "minecraft:block/cube_all", textures: [all: "canaplia:block/resin_block_${key}"]])
parent: "minecraft:block/cube_all", textures: [all: "canaplia:block/${resin}"]])
resinVariants["variant=${key}"] = [model: "canaplia:block/resin_block_${key}"]
writePng("assets/canaplia/textures/item/${seed}.png") { image ->
[[7,6],[9,7],[6,9],[9,10],[7,12]].eachWithIndex { point, index -> image.setRGB(point[0], point[1], argb(shade(color, 0.65 + index * 0.10))) }
}
writePng("assets/canaplia/textures/item/${bud}.png") { image ->
for (int y = 4; y <= 12; y++) for (int x = 4; x <= 11; x++) {
if (Math.pow((x - 7.5) / 4.0, 2) + Math.pow((y - 8.0) / 5.0, 2) <= 1.0) image.setRGB(x, y, argb(shade(color, 0.72 + (x + y) % 4 * 0.11)))
}
image.setRGB(7, 13, argb(0x4E8A47)); image.setRGB(8, 13, argb(0x4E8A47))
}
writePng("assets/canaplia/textures/item/${joint}.png") { image ->
for (int i = 0; i < 9; i++) { image.setRGB(3 + i, 12 - i, argb(i < 2 ? color : 0xD8C6A0)); if (i > 1 && i < 8) image.setRGB(4 + i, 12 - i, argb(0xB39A72)) }
}
writePng("assets/canaplia/textures/block/resin_block_${key}.png") { image ->
for (int y = 0; y < 16; y++) for (int x = 0; x < 16; x++) image.setRGB(x, y, argb(shade(color, 0.62 + ((x / 4 + y / 4) % 3) * 0.13)))
}
writeJson("data/canaplia/recipe/${joint}.json", [
type: "minecraft:crafting_shapeless", category: "misc",
ingredients: ["minecraft:paper", "canaplia:${bud}"], result: [id: "canaplia:${joint}"]])
@@ -201,15 +153,13 @@ tasks.register("generateCanapliaResources") {
writeJson("data/c/tags/item/seeds.json", [replace: false, values: seedIds])
writeJson("data/canaplia/tags/item/hemp_buds.json", [replace: false, values: budIds])
generatedItem("codex")
itemDefinition("codex", "minecraft:item/enchanted_book")
itemDefinition("codex_page", "minecraft:item/paper")
writeJson("data/canaplia/recipe/codex.json", [
type: "minecraft:crafting_shapeless", category: "misc",
ingredients: ["minecraft:book", "minecraft:green_dye"], result: [id: "canaplia:codex"]])
writePng("assets/canaplia/textures/item/codex.png") { image ->
for (int y = 2; y < 14; y++) for (int x = 2; x < 14; x++) image.setRGB(x, y, argb(x < 8 ? 0x6D8A55 : 0x789A61))
for (int y = 3; y < 13; y++) image.setRGB(7, y, argb(0xD8CDA6))
image.setRGB(5, 6, argb(0xE4B83E)); image.setRGB(10, 9, argb(0x8154A8))
}
type: "minecraft:crafting_shaped", category: "misc",
pattern: [" P ", "PBP", " P "],
key: [P: "canaplia:codex_page", B: "minecraft:book"],
result: [id: "canaplia:codex"]])
}
}
@@ -606,7 +556,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.33"
if (project.version.toString() != "0.0.0-alpha.36"
|| climateMigration.target?.pack_version != "26.2.0-alpha.157"
|| climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24"
|| !climateSource.contains("PlayerBlockBreakEvents.AFTER.register")
@@ -1363,7 +1313,7 @@ tasks.register("verifyCulinaryReliability") {
}
}
}
if (project.version.toString() != "0.0.0-alpha.33"
if (project.version.toString() != "0.0.0-alpha.36"
|| 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
@@ -1401,7 +1351,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.33"
if (project.version.toString() != "0.0.0-alpha.36"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.cauldron_batches?.logical_stack_limit != 64
|| migration.cauldron_batches?.identity != "same_item_same_components"
@@ -1439,7 +1389,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.33"
if (project.version.toString() != "0.0.0-alpha.36"
|| 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
@@ -1487,7 +1437,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.33"
if (project.version.toString() != "0.0.0-alpha.36"
|| 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
@@ -1511,9 +1461,79 @@ tasks.register("verifyCulinaryLecternRelease") {
}
}
tasks.register("verifyCanapliaForbiddenBook") {
group = "verification"
description = "Checks personal Forbidden Books, random page learning and lectern sharing."
dependsOn tasks.named("generateCanapliaResources")
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java"))
inputs.file(file("src/main/resources/data/minecraft/tags/item/lectern_books.json"))
inputs.files(fileTree("src/main/resources/assets/canaplia/lang") { include "*.json" })
inputs.file(rootProject.file("CANAPLIA.md"))
doLast {
def knowledge = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java").text
def knowledgeData = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java").text
def lectern = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java").text
def pageItem = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java").text
def screen = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java").text
def components = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java").text
def lecternBooks = new JsonSlurper().parse(file(
"src/main/resources/data/minecraft/tags/item/lectern_books.json"))
def generatedCodex = new JsonSlurper().parse(new File(
generatedCanapliaResources.get().asFile, "assets/canaplia/items/codex.json"))
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/canaplia/lang/${locale}.json"))]
}
def expectedNames = [
fr_fr: "Livre interdit",
en_us: "Forbidden Book",
ru_ru: "Запретная книга"]
if (project.version.toString() != "0.0.0-alpha.36"
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|| !knowledge.contains("data.personal(inscription.ownerId())")
|| knowledge.contains("data.shared()")
|| !knowledgeData.contains("the legacy shared field is preserved")
|| !lectern.contains("UseBlockCallback.EVENT.register")
|| !lectern.contains("PlayerBlockBreakEvents.BEFORE.register")
|| !lectern.contains("CanapliaKnowledge.open(serverPlayer, book)")
|| !lectern.contains("CanapliaKnowledge.forgetInscription(taken)")
|| screen.contains("PAGE_BACKGROUND")
|| screen.contains("extractBackground(GuiGraphicsExtractor")
|| screen.contains("extractRenderState(GuiGraphicsExtractor")
|| !screen.contains("ChatFormatting.DARK_GRAY")
|| !screen.contains("ChatFormatting.DARK_GREEN")
|| screen.contains("screen.canaplia.codex.introduction")
|| !components.contains('ItsAliveMod.id("canaplia_codex")')
|| lecternBooks["values"] != ["itsalive:culinary_recipe_book", "canaplia:codex"]
|| generatedCodex.model?.model != "minecraft:item/enchanted_book"
|| locales.any { locale, values ->
values["item.canaplia.codex"] != expectedNames[locale]
|| values["screen.canaplia.codex.title"] != expectedNames[locale]
|| !values.containsKey("message.canaplia.codex_page.complete")
|| !values.containsKey("tooltip.canaplia.codex.personal")
}) {
throw new GradleException("The Canaplia Forbidden Book contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyItsAlive")
dependsOn tasks.named("canapliaFoundationSmoke")
dependsOn tasks.named("verifyCanapliaForbiddenBook")
dependsOn tasks.named("verifyCulinaryReliability")
dependsOn tasks.named("verifyCauldronBatching")
dependsOn tasks.named("culinaryBatchingSmoke")
@@ -13,7 +13,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 = 2;
public static final int CURRENT_DATA_VERSION = 3;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ItsAliveMod() {
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.monster.Enemy;
import net.minecraft.world.item.ItemStack;
/** Bounded player-kill drops that make the Codex and its loose pages survival rewards. */
public final class CanapliaCodexDrops {
public static final int PAGE_DROP_DENOMINATOR = 12;
public static final int CODEX_DROP_DENOMINATOR = 256;
private static boolean initialized;
private CanapliaCodexDrops() {}
public static void initialize() {
if (initialized) return;
initialized = true;
ServerLivingEntityEvents.AFTER_DEATH.register((entity, damageSource) -> {
if (!(entity instanceof Enemy)
|| !(entity.level() instanceof ServerLevel level)
|| !(damageSource.getEntity() instanceof ServerPlayer)) return;
if (dropsPage(level.getRandom().nextInt(PAGE_DROP_DENOMINATOR))) {
entity.spawnAtLocation(level, new ItemStack(CanapliaContent.CODEX_PAGE));
}
if (dropsCodex(level.getRandom().nextInt(CODEX_DROP_DENOMINATOR))) {
entity.spawnAtLocation(level, new ItemStack(CanapliaContent.CODEX));
}
});
}
static boolean dropsPage(int roll) {
return roll == 0;
}
static boolean dropsCodex(int roll) {
return roll == 0;
}
}
@@ -0,0 +1,43 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.UUID;
import net.minecraft.core.UUIDUtil;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Binds a placed Forbidden Book to the player whose knowledge it shares. */
public record CanapliaCodexInscription(UUID ownerId, String ownerName) {
private static final int MAX_NAME_LENGTH = 32;
public static final Codec<CanapliaCodexInscription> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
UUIDUtil.CODEC.fieldOf("owner_id").forGetter(CanapliaCodexInscription::ownerId),
Codec.STRING.fieldOf("owner_name").forGetter(CanapliaCodexInscription::ownerName)
).apply(instance, CanapliaCodexInscription::new)
);
public static final StreamCodec<RegistryFriendlyByteBuf, CanapliaCodexInscription> STREAM_CODEC =
StreamCodec.composite(
UUIDUtil.STREAM_CODEC,
CanapliaCodexInscription::ownerId,
ByteBufCodecs.STRING_UTF8,
CanapliaCodexInscription::ownerName,
CanapliaCodexInscription::new
);
public CanapliaCodexInscription {
if (ownerId == null) throw new IllegalArgumentException("Forbidden Book owner is required");
ownerName = sanitizeName(ownerName);
}
public static CanapliaCodexInscription of(UUID ownerId, String ownerName) {
return new CanapliaCodexInscription(ownerId, ownerName);
}
private static String sanitizeName(String ownerName) {
String cleaned = ownerName == null ? "" : ownerName.replaceAll("\\p{Cntrl}", "").trim();
if (cleaned.length() > MAX_NAME_LENGTH) cleaned = cleaned.substring(0, MAX_NAME_LENGTH);
return cleaned.isEmpty() ? "?" : cleaned;
}
}
@@ -6,6 +6,7 @@ import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempTopBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlockEntity;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaCodexItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaCodexPageItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaJointItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaVariantBlockItem;
import java.util.ArrayList;
@@ -36,6 +37,8 @@ public final class CanapliaContent {
key -> new CanapliaResinBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.HONEY_BLOCK).setId(key)));
public static final BlockEntityType<CanapliaResinBlockEntity> RESIN_BLOCK_ENTITY_TYPE = registerResinBlockEntity();
private static final Map<CanapliaVariantKey, Family> FAMILIES = registerFamilies();
public static final Item CODEX_PAGE = registerItem("codex_page", key ->
new CanapliaCodexPageItem(new Item.Properties().setId(key)));
public static final Item CODEX = registerItem("codex", key ->
new CanapliaCodexItem(new Item.Properties().stacksTo(1).setId(key)));
private static final List<Item> CREATIVE_ITEMS = createCreativeItems();
@@ -92,7 +95,7 @@ public final class CanapliaContent {
}
private static List<Item> createCreativeItems() {
List<Item> items = new ArrayList<>(65);
List<Item> items = new ArrayList<>(66);
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
Family family = family(variant);
items.add(family.seeds());
@@ -100,6 +103,7 @@ public final class CanapliaContent {
items.add(family.resin());
items.add(family.joint());
}
items.add(CODEX_PAGE);
items.add(CODEX);
return List.copyOf(items);
}
@@ -5,7 +5,7 @@ import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
/** Initializes the complete compatibility implementation and its public module boundaries. */
public final class CanapliaFoundation {
public static final int FOUNDATION_VERSION = 2;
public static final int FOUNDATION_VERSION = 3;
private static boolean initialized;
private CanapliaFoundation() {
@@ -21,6 +21,8 @@ public final class CanapliaFoundation {
throw new IllegalStateException("Unsupported Ambiance visual contract");
}
CanapliaContent.register();
CanapliaLectern.initialize();
CanapliaCodexDrops.initialize();
CanapliaDiscoverySources.register();
CanapliaDiscovery.initialize();
CanapliaHarvestService.initialize();
@@ -1,11 +1,16 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveDataComponents;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Trusted discovery service and compact Codex snapshot builder. */
public final class CanapliaKnowledge {
@@ -22,10 +27,50 @@ public final class CanapliaKnowledge {
}
public static void open(ServerPlayer player) {
open(player, player.getUUID(), Set.of());
}
public static void open(ServerPlayer player, ItemStack book) {
CanapliaCodexInscription inscription = book.get(ItsAliveDataComponents.CANAPLIA_CODEX);
if (inscription == null) {
open(player);
return;
}
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
open(player, player.getUUID(), data.personal(inscription.ownerId()));
}
public static boolean learnRandomUnknown(ServerPlayer player) {
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
List<CanapliaVariantKey> unknown = unknownVariants(data.personal(player.getUUID()));
if (unknown.isEmpty()) {
player.sendSystemMessage(Component.translatable("message.canaplia.codex_page.complete"));
return false;
}
CanapliaVariantKey selected = unknown.get(player.getRandom().nextInt(unknown.size()));
return discover(player, selected, "codex_page");
}
public static void inscribe(ItemStack stack, ServerPlayer player) {
if (!stack.is(CanapliaContent.CODEX)) return;
stack.set(ItsAliveDataComponents.CANAPLIA_CODEX, CanapliaCodexInscription.of(
player.getUUID(), player.getGameProfile().name()));
}
public static void forgetInscription(ItemStack stack) {
if (stack.is(CanapliaContent.CODEX)) stack.remove(ItsAliveDataComponents.CANAPLIA_CODEX);
}
static List<CanapliaVariantKey> unknownVariants(Set<String> learned) {
return Arrays.stream(CanapliaVariantKey.values())
.filter(variant -> !learned.contains(variant.key()))
.toList();
}
private static void open(ServerPlayer player, UUID personalOwner, Set<String> shared) {
if (!ServerPlayNetworking.canSend(player, CanapliaCodexPayload.ID)) return;
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
Set<String> personal = data.personal(player.getUUID());
Set<String> shared = data.shared();
Set<String> personal = data.personal(personalOwner);
int personalMask = mask(personal);
int sharedMask = mask(shared);
int originHints = 0;
@@ -15,7 +15,7 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Versioned personal and shared discoveries; unknown future keys survive round-trips. */
/** Versioned personal discoveries; the legacy shared field is preserved but no longer exposed globally. */
public final class CanapliaKnowledgeData extends SavedData {
public static final int DATA_VERSION = 1;
private static final Codec<CanapliaKnowledgeData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -57,8 +57,7 @@ public final class CanapliaKnowledgeData extends SavedData {
public boolean discover(UUID player, String key) {
if (!validKey(key)) return false;
boolean personal = players.computeIfAbsent(player.toString(), ignored -> new LinkedHashSet<>()).add(key);
boolean community = shared.add(key);
if (personal || community) setDirty();
if (personal) setDirty();
return personal;
}
@@ -0,0 +1,77 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.LecternBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** Shares only the placer's Canaplia knowledge while the Forbidden Book remains on a lectern. */
public final class CanapliaLectern {
private CanapliaLectern() {
}
public static void initialize() {
UseBlockCallback.EVENT.register(CanapliaLectern::use);
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
if (blockEntity instanceof LecternBlockEntity lectern) {
CanapliaKnowledge.forgetInscription(lectern.getBook());
}
return true;
});
}
private static InteractionResult use(Player player, Level level, InteractionHand hand, BlockHitResult hit) {
if (hand != InteractionHand.MAIN_HAND || player.isSpectator()) return InteractionResult.PASS;
BlockPos pos = hit.getBlockPos();
BlockState state = level.getBlockState(pos);
if (!state.is(Blocks.LECTERN)) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
if (!state.getValue(LecternBlock.HAS_BOOK)) {
if (held.is(CanapliaContent.CODEX)
&& !level.isClientSide()
&& player instanceof ServerPlayer serverPlayer) {
CanapliaKnowledge.inscribe(held, serverPlayer);
}
return InteractionResult.PASS;
}
if (!(level.getBlockEntity(pos) instanceof LecternBlockEntity lectern)) {
return InteractionResult.PASS;
}
ItemStack book = lectern.getBook();
if (!book.is(CanapliaContent.CODEX)) return InteractionResult.PASS;
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer) || !level.mayInteract(serverPlayer, pos)) {
return InteractionResult.FAIL;
}
if (serverPlayer.isSecondaryUseActive()) {
return takeBook(serverPlayer, level, pos, state, lectern);
}
CanapliaKnowledge.open(serverPlayer, book);
return InteractionResult.SUCCESS;
}
private static InteractionResult takeBook(ServerPlayer player, Level level, BlockPos pos,
BlockState state, LecternBlockEntity lectern) {
ItemStack taken = lectern.getBook().copy();
if (taken.isEmpty()) return InteractionResult.PASS;
CanapliaKnowledge.forgetInscription(taken);
lectern.setBook(ItemStack.EMPTY);
LecternBlock.resetBookState(player, level, pos, state, false);
player.getInventory().placeItemBackInInventory(taken);
level.playSound(null, pos, SoundEvents.ITEM_FRAME_REMOVE_ITEM, SoundSource.BLOCKS, 1.0F, 1.0F);
return InteractionResult.SUCCESS;
}
}
@@ -28,7 +28,7 @@ import net.minecraft.world.phys.BlockHitResult;
/** Shared seven-age crop retaining one of the sixteen stable variant keys in block state. */
public final class CanapliaHempCropBlock extends CropBlock {
public static final EnumProperty<CanapliaVariantKey> VARIANT = EnumProperty.create("variant", CanapliaVariantKey.class);
public static final int UPPER_START_AGE = 5;
public static final int UPPER_START_AGE = 7;
private static final int HARVEST_RESET_AGE = 3;
public CanapliaHempCropBlock(BlockBehaviour.Properties properties) {
@@ -3,7 +3,20 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.stats.Stats;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
@@ -14,15 +27,21 @@ import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.phys.BlockHitResult;
/** Shared resin block with a bounded, server-ticked aura. */
public final class CanapliaResinBlock extends Block implements EntityBlock {
public static final BooleanProperty LIT = BlockStateProperties.LIT;
public static final EnumProperty<CanapliaVariantKey> VARIANT = EnumProperty.create("variant", CanapliaVariantKey.class);
public CanapliaResinBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(defaultBlockState().setValue(VARIANT, CanapliaVariantKey.COMMON));
registerDefaultState(defaultBlockState().setValue(LIT, false)
.setValue(VARIANT, CanapliaVariantKey.COMMON));
}
@Override
@@ -32,13 +51,83 @@ public final class CanapliaResinBlock extends Block implements EntityBlock {
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
return type == CanapliaContent.RESIN_BLOCK_ENTITY_TYPE
return !level.isClientSide() && type == CanapliaContent.RESIN_BLOCK_ENTITY_TYPE
? (tickerLevel, pos, tickerState, blockEntity) ->
CanapliaResinBlockEntity.tick(tickerLevel, pos, tickerState,
(CanapliaResinBlockEntity) blockEntity)
: null;
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (state.getValue(LIT) || !isIgnitionItem(stack)) return InteractionResult.TRY_WITH_EMPTY_HAND;
if (level.isClientSide()) return InteractionResult.SUCCESS;
igniteWithItem((ServerLevel) level, pos, state, player, hand);
return InteractionResult.SUCCESS_SERVER;
}
@Override
protected void onProjectileHit(Level level, BlockState state, BlockHitResult hit, Projectile projectile) {
if (!(level instanceof ServerLevel serverLevel) || state.getValue(LIT) || !projectile.isOnFire()) return;
BlockPos pos = hit.getBlockPos();
if (projectile.mayInteract(serverLevel, pos)) {
ignite(serverLevel, pos, state, SoundEvents.FIRECHARGE_USE, 0.9F);
}
}
@Override
public void animateTick(BlockState state, Level level, BlockPos pos, RandomSource random) {
if (!state.getValue(LIT)) return;
if (random.nextInt(10) == 0) {
level.playLocalSound(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D,
SoundEvents.CAMPFIRE_CRACKLE, SoundSource.BLOCKS,
0.35F + random.nextFloat() * 0.3F, 0.6F + random.nextFloat() * 0.4F, false);
}
spawnSmoke(level, pos, random);
if (random.nextBoolean()) spawnSmoke(level, pos, random);
}
private static void spawnSmoke(Level level, BlockPos pos, RandomSource random) {
level.addParticle(ParticleTypes.CAMPFIRE_COSY_SMOKE,
pos.getX() + 0.5D, pos.getY() + 1.0D, pos.getZ() + 0.5D,
random.nextDouble() * 0.01D, random.nextDouble() * 0.02D,
random.nextDouble() * 0.01D);
}
private static boolean isIgnitionItem(ItemStack stack) {
return stack.is(Items.FLINT_AND_STEEL) || stack.is(Items.FIRE_CHARGE);
}
private static void igniteWithItem(ServerLevel level, BlockPos pos, BlockState state,
Player player, InteractionHand hand) {
ItemStack stack = player.getItemInHand(hand);
if (stack.is(Items.FLINT_AND_STEEL)) {
if (player instanceof ServerPlayer serverPlayer) {
stack.hurtAndBreak(1, level, serverPlayer, item -> {});
}
player.awardStat(Stats.ITEM_USED.get(Items.FLINT_AND_STEEL));
ignite(level, pos, state, SoundEvents.FLINTANDSTEEL_USE, 1.0F);
return;
}
stack.consume(1, player);
player.awardStat(Stats.ITEM_USED.get(Items.FIRE_CHARGE));
ignite(level, pos, state, SoundEvents.FIRECHARGE_USE, 0.9F);
}
private static void ignite(ServerLevel level, BlockPos pos, BlockState state,
SoundEvent sound, float pitch) {
BlockState litState = state.setValue(LIT, true);
level.setBlock(pos, litState, Block.UPDATE_ALL);
if (level.getBlockEntity(pos) instanceof CanapliaResinBlockEntity blockEntity) {
blockEntity.light();
}
level.playSound(null, pos, sound, SoundSource.BLOCKS, 1.0F,
pitch + level.getRandom().nextFloat() * 0.2F);
level.gameEvent(GameEvent.BLOCK_CHANGE, pos, GameEvent.Context.of(litState));
}
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(VARIANT)).resin());
@@ -46,6 +135,6 @@ public final class CanapliaResinBlock extends Block implements EntityBlock {
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(VARIANT);
builder.add(LIT, VARIANT);
}
}
@@ -3,22 +3,29 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaEffects;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.phys.AABB;
/** Persisted ninety-second resin aura; the source variant lives in the block state. */
public final class CanapliaResinBlockEntity extends BlockEntity {
public static final int DATA_VERSION = 1;
public static final int DATA_VERSION = 2;
public static final int DEFAULT_BURN_TICKS = 90 * 20;
private static final int AURA_INTERVAL = 40;
private static final double AURA_RADIUS = 4.0D;
private int remainingBurnTicks = DEFAULT_BURN_TICKS;
private int remainingBurnTicks;
private boolean migrateLegacyBurn;
public CanapliaResinBlockEntity(BlockPos pos, BlockState state) {
super(CanapliaContent.RESIN_BLOCK_ENTITY_TYPE, pos, state);
@@ -26,8 +33,16 @@ public final class CanapliaResinBlockEntity extends BlockEntity {
public static void tick(Level level, BlockPos pos, BlockState state, CanapliaResinBlockEntity entity) {
if (!(level instanceof ServerLevel serverLevel)) return;
if (entity.migrateLegacyBurn) {
entity.migrateLegacyBurn = false;
if (!state.getValue(CanapliaResinBlock.LIT) && entity.remainingBurnTicks > 0) {
state = state.setValue(CanapliaResinBlock.LIT, true);
serverLevel.setBlock(pos, state, Block.UPDATE_ALL);
}
}
if (!state.getValue(CanapliaResinBlock.LIT)) return;
if (entity.remainingBurnTicks <= 0) {
serverLevel.removeBlock(pos, false);
extinguishAndConsume(serverLevel, pos, state);
return;
}
if (entity.remainingBurnTicks % AURA_INTERVAL == 0) {
@@ -37,10 +52,16 @@ public final class CanapliaResinBlockEntity extends BlockEntity {
}
}
entity.remainingBurnTicks--;
if (entity.remainingBurnTicks <= 0) serverLevel.removeBlock(pos, false);
if (entity.remainingBurnTicks <= 0) extinguishAndConsume(serverLevel, pos, state);
else if (entity.remainingBurnTicks % 20 == 0) entity.setChanged();
}
public void light() {
remainingBurnTicks = DEFAULT_BURN_TICKS;
migrateLegacyBurn = false;
setChanged();
}
public int remainingBurnTicks() {
return remainingBurnTicks;
}
@@ -55,7 +76,18 @@ public final class CanapliaResinBlockEntity extends BlockEntity {
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
int dataVersion = input.getIntOr("data_version", 1);
remainingBurnTicks = Math.clamp(
input.getIntOr("remaining_burn_ticks", DEFAULT_BURN_TICKS), 0, DEFAULT_BURN_TICKS);
input.getIntOr("remaining_burn_ticks", dataVersion < DATA_VERSION ? DEFAULT_BURN_TICKS : 0),
0, DEFAULT_BURN_TICKS);
migrateLegacyBurn = dataVersion < DATA_VERSION && remainingBurnTicks > 0;
}
private static void extinguishAndConsume(ServerLevel level, BlockPos pos, BlockState state) {
level.playSound(null, pos, SoundEvents.FIRE_EXTINGUISH, SoundSource.BLOCKS, 0.8F, 1.0F);
level.sendParticles(ParticleTypes.SMOKE, pos.getX() + 0.5D, pos.getY() + 0.85D,
pos.getZ() + 0.5D, 20, 0.25D, 0.15D, 0.25D, 0.01D);
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
level.gameEvent(GameEvent.BLOCK_CHANGE, pos, GameEvent.Context.of(state));
}
}
@@ -2,82 +2,57 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.client;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.screens.inventory.BookViewScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
/** Compact two-page personal/shared catalogue with progressively revealed hints. */
public final class CanapliaCodexScreen extends Screen {
private static final int ENTRIES_PER_PAGE = 8;
private final Screen parent;
private final CanapliaCodexPayload snapshot;
private int page;
public CanapliaCodexScreen(Screen parent, CanapliaCodexPayload snapshot) {
super(Component.translatable("screen.canaplia.codex.title"));
this.parent = parent;
this.snapshot = snapshot;
/** Vanilla book presentation of the server-authored Canaplia catalogue. */
public final class CanapliaCodexScreen extends BookViewScreen {
public CanapliaCodexScreen(CanapliaCodexPayload snapshot) {
super(new BookAccess(pages(snapshot)));
}
@Override
protected void init() {
int y = Math.max(6, height - 28);
addRenderableWidget(Button.builder(Component.literal(""), button -> changePage(-1))
.bounds(width / 2 - 104, y, 24, 20).build()).active = page > 0;
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(width / 2 - 76, y, 152, 20).build());
addRenderableWidget(Button.builder(Component.literal(""), button -> changePage(1))
.bounds(width / 2 + 80, y, 24, 20).build()).active = page < 1;
}
private void changePage(int delta) {
page = Math.clamp(page + delta, 0, 1);
rebuildWidgets();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
graphics.fill(width / 2 - 172, 5, width / 2 + 172, height - 34, 0xD0182118);
graphics.centeredText(font, title, width / 2, 12, 0xFFE8F1CF);
graphics.centeredText(font, Component.translatable("screen.canaplia.codex.page", page + 1, 2),
width / 2, 25, 0xFF9DB886);
int start = page * ENTRIES_PER_PAGE;
int y = 43;
private static List<Component> pages(CanapliaCodexPayload snapshot) {
List<Component> pages = new ArrayList<>(CanapliaVariantKey.values().length);
CanapliaVariantKey[] variants = CanapliaVariantKey.values();
for (int index = start; index < Math.min(start + ENTRIES_PER_PAGE, variants.length); index++) {
drawEntry(graphics, variants[index], index, y);
y += 24;
for (int index = 0; index < variants.length; index++) {
pages.add(variantPage(snapshot, variants[index], index));
}
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
return List.copyOf(pages);
}
private void drawEntry(GuiGraphicsExtractor graphics, CanapliaVariantKey variant, int index, int y) {
private static Component variantPage(CanapliaCodexPayload snapshot,
CanapliaVariantKey variant, int index) {
boolean personal = bit(snapshot.personalMask(), index);
boolean shared = bit(snapshot.sharedMask(), index);
boolean originHint = bit(snapshot.originHintMask(), index);
boolean conditionHint = bit(snapshot.conditionHintMask(), index);
int x = width / 2 - 156;
int color = personal ? 0xFFF0F4D7 : shared ? 0xFF9BA697 : 0xFF566056;
Component name = personal
? Component.translatable("variant.canaplia." + variant.key())
: shared ? Component.translatable("screen.canaplia.codex.silhouette")
: Component.translatable("screen.canaplia.codex.hidden");
graphics.text(font, name, x, y, color, false);
if (personal || originHint) {
graphics.text(font, Component.translatable("origin.canaplia." + variant.key()),
x + 104, y, 0xFF9DB886, false);
MutableComponent page = Component.translatable("screen.canaplia.codex.entry_number", index + 1)
.withStyle(ChatFormatting.DARK_GRAY).append("\n\n");
if (!personal && !shared) {
return page.append(Component.translatable("screen.canaplia.codex.hidden")
.withStyle(ChatFormatting.GRAY));
}
if (conditionHint || personal) {
graphics.text(font, Component.translatable("hint.canaplia." + variant.key()),
x + 8, y + 11, personal ? 0xFFBFC9AC : 0xFF75836D, false);
page.append(Component.translatable("variant.canaplia." + variant.key())
.withStyle(personal ? ChatFormatting.DARK_GREEN : ChatFormatting.DARK_PURPLE));
if (personal || shared || originHint) {
page.append("\n\n").append(Component.translatable("screen.canaplia.codex.origin",
Component.translatable("origin.canaplia." + variant.key()))
.withStyle(ChatFormatting.DARK_GRAY));
}
if (personal || shared || conditionHint) {
page.append("\n\n").append(Component.translatable("hint.canaplia." + variant.key())
.withStyle(ChatFormatting.DARK_GRAY));
}
return page;
}
private static boolean bit(int mask, int index) { return (mask & (1 << index)) != 0; }
@Override
public void onClose() {
if (minecraft != null) minecraft.gui.setScreen(parent);
private static boolean bit(int mask, int index) {
return (mask & (1 << index)) != 0;
}
}
@@ -1,11 +1,17 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaKnowledge;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
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;
import net.minecraft.world.level.Level;
/** Opens the server-authored personal/shared Canaplia collection snapshot. */
@@ -20,4 +26,11 @@ public final class CanapliaCodexItem extends Item {
if (player instanceof ServerPlayer serverPlayer) CanapliaKnowledge.open(serverPlayer);
return InteractionResult.SUCCESS_SERVER;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.canaplia.codex.personal")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@@ -0,0 +1,28 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaKnowledge;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/** Consumable unidentified page which discovers one random unknown Canaplia entry. */
public final class CanapliaCodexPageItem extends Item {
public CanapliaCodexPageItem(Properties properties) {
super(properties);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS;
if (CanapliaKnowledge.learnRandomUnknown(serverPlayer)) {
ItemStack held = player.getItemInHand(hand);
if (!player.getAbilities().instabuild) held.shrink(1);
}
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -3,13 +3,18 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaEffects;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaKnowledge;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ItemUtils;
import net.minecraft.world.item.ItemUseAnimation;
import net.minecraft.world.level.Level;
@@ -29,8 +34,7 @@ public final class CanapliaJointItem extends Item {
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
player.startUsingItem(hand);
return InteractionResult.CONSUME;
return ItemUtils.startUsingInstantly(level, player, hand);
}
@Override
@@ -38,14 +42,22 @@ public final class CanapliaJointItem extends Item {
if (!level.isClientSide() && consumer instanceof ServerPlayer player) {
CanapliaKnowledge.discover(player, variant, "consumption");
CanapliaEffects.consume(player, variant);
if (!player.hasInfiniteMaterials()) stack.shrink(1);
level.playSound(null, consumer.getX(), consumer.getY(), consumer.getZ(),
SoundEvents.SMOKER_SMOKE, SoundSource.PLAYERS, 0.35F,
0.9F + level.getRandom().nextFloat() * 0.2F);
if (level instanceof ServerLevel serverLevel) {
serverLevel.sendParticles(ParticleTypes.SMOKE, consumer.getX(),
consumer.getY() + consumer.getBbHeight() * 0.6D, consumer.getZ(),
8, 0.12D, 0.12D, 0.12D, 0.01D);
}
stack.consume(1, consumer);
}
return stack;
}
@Override
public ItemUseAnimation getUseAnimation(ItemStack stack) {
return ItemUseAnimation.TOOT_HORN;
return ItemUseAnimation.DRINK;
}
@Override
@@ -52,7 +52,7 @@ public final class ItsAliveClient implements ClientModInitializer {
context.client().execute(() -> CulinaryRecipeBookClient.open(payload)));
ClientPlayNetworking.registerGlobalReceiver(CanapliaCodexPayload.ID, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new CanapliaCodexScreen(context.client().gui.screen(), payload))));
new CanapliaCodexScreen(payload))));
ClientTickEvents.END_CLIENT_TICK.register(client -> VomiterOverlayState.tick());
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> VomiterOverlayState.clear());
ModelLayerRegistry.registerModelLayer(ButterflyModel.LAYER_LOCATION, ButterflyModel::createBodyLayer);
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive.registry;
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaCodexInscription;
import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryBookInscription;
import net.minecraft.core.Registry;
import net.minecraft.core.component.DataComponentType;
@@ -8,6 +9,15 @@ import net.minecraft.core.registries.BuiltInRegistries;
/** Persistent item components owned by It's Alive. */
public final class ItsAliveDataComponents {
public static final DataComponentType<CanapliaCodexInscription> CANAPLIA_CODEX = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
ItsAliveMod.id("canaplia_codex"),
DataComponentType.<CanapliaCodexInscription>builder()
.persistent(CanapliaCodexInscription.CODEC)
.networkSynchronized(CanapliaCodexInscription.STREAM_CODEC)
.cacheEncoding()
.build()
);
public static final DataComponentType<CulinaryBookInscription> CULINARY_BOOK = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
ItsAliveMod.id("culinary_book"),
@@ -1,15 +1,19 @@
{
"description.canaplia": "Complete Canaplia gameplay: sixteen plants, server-authoritative discovery, hybrids, effects and an Ambiance-owned visual layer.",
"screen.canaplia.codex.title": "Canaplia Codex",
"screen.canaplia.codex.title": "Forbidden Book",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.entry_number": "Entry %s",
"screen.canaplia.codex.origin": "Origin: %s",
"screen.canaplia.codex.hidden": "Undiscovered",
"screen.canaplia.codex.silhouette": "Known silhouette",
"message.canaplia.discovered": "Canaplia discovered: %s",
"message.canaplia.codex_page.complete": "You already know every entry in the Forbidden Book.",
"message.canaplia.composter.mixed": "This composter already contains another Canaplia variant.",
"tooltip.canaplia.codex.personal": "Personal in hand; shares its owner's knowledge on a lectern",
"block.canaplia.hemp_crop": "Canaplia Plant",
"block.canaplia.hemp_top": "Canaplia Plant Top",
"block.canaplia.resin_block": "Canaplia Resin Block",
"item.canaplia.codex": "Canaplia Codex",
"item.canaplia.codex": "Forbidden Book",
"item.canaplia.codex_page": "Canaplia Page",
"variant.canaplia.common": "Common",
"origin.canaplia.common": "Wild",
"hint.canaplia.common": "Search ordinary Overworld vegetation.",
@@ -1,15 +1,19 @@
{
"description.canaplia": "Gameplay Canaplia complet : seize plantes, découvertes, hybrides et effets autoritaires côté serveur, avec une couche visuelle gérée par Ambiance.",
"screen.canaplia.codex.title": "Codex Canaplia",
"screen.canaplia.codex.title": "Livre interdit",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.entry_number": "Entrée %s",
"screen.canaplia.codex.origin": "Origine : %s",
"screen.canaplia.codex.hidden": "Non découverte",
"screen.canaplia.codex.silhouette": "Silhouette connue",
"message.canaplia.discovered": "Canaplia découverte : %s",
"message.canaplia.codex_page.complete": "Tu connais déjà toutes les entrées du Livre interdit.",
"message.canaplia.composter.mixed": "Ce composteur contient déjà une autre variante de Canaplia.",
"tooltip.canaplia.codex.personal": "Personnel en main ; partage le savoir du propriétaire sur un pupitre",
"block.canaplia.hemp_crop": "Plant de Canaplia",
"block.canaplia.hemp_top": "Sommet de Canaplia",
"block.canaplia.resin_block": "Bloc de résine Canaplia",
"item.canaplia.codex": "Codex Canaplia",
"item.canaplia.codex": "Livre interdit",
"item.canaplia.codex_page": "Page de Canaplia",
"variant.canaplia.common": "Commune",
"origin.canaplia.common": "Sauvage",
"hint.canaplia.common": "Cherchez dans la végétation ordinaire de l'Overworld.",
@@ -1,15 +1,19 @@
{
"description.canaplia": "Полная механика Canaplia: шестнадцать растений, серверные открытия, гибриды и эффекты с визуальным слоем Ambiance.",
"screen.canaplia.codex.title": "Кодекс Canaplia",
"screen.canaplia.codex.title": "Запретная книга",
"screen.canaplia.codex.page": "Страница %s / %s",
"screen.canaplia.codex.entry_number": "Запись %s",
"screen.canaplia.codex.origin": "Происхождение: %s",
"screen.canaplia.codex.hidden": "Не открыто",
"screen.canaplia.codex.silhouette": "Известный силуэт",
"message.canaplia.discovered": "Открыта Canaplia: %s",
"message.canaplia.codex_page.complete": "Вы уже знаете все записи Запретной книги.",
"message.canaplia.composter.mixed": "В этом компостере уже находится другой вариант Canaplia.",
"tooltip.canaplia.codex.personal": "Личная в руке; на кафедре делится знаниями владельца",
"block.canaplia.hemp_crop": "Растение Canaplia",
"block.canaplia.hemp_top": "Верхушка Canaplia",
"block.canaplia.resin_block": "Блок смолы Canaplia",
"item.canaplia.codex": "Кодекс Canaplia",
"item.canaplia.codex": "Запретная книга",
"item.canaplia.codex_page": "Страница Canaplia",
"variant.canaplia.common": "Обычная",
"origin.canaplia.common": "Дикая",
"hint.canaplia.common": "Ищите среди обычной растительности Верхнего мира.",
@@ -0,0 +1,9 @@
# Canaplia texture provenance
The 224 pixel-art sprites in `block/` and `item/` are the authored Canaplia
textures restored from the copyright holder's local Sanctuary source tree at
commit `b14fbe561ac99054f17ae56caee9eebee3dbcbb6`.
They are included in Sanctuary 26.2 at the copyright holder's explicit request.
Minecraft book, paper, and book-screen assets remain referenced from the
`minecraft` namespace and are not copied here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

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