Compare commits

..
Author SHA1 Message Date
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
koka b708bf5ffa feat(canaplia): implement complete gameplay integration
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 03:17:26 +02:00
koka 2e1585f12f Merge pull request 'feat(canaplia): porter les fondations en 26.2 (CANA-01)' (#53) from feature/canaplia-foundation into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #53
2026-08-29 00:12:58 +00:00
koka 1a8a983506 feat(canaplia): add 26.2 foundation
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 02:04:28 +02:00
koka 7db698881e Merge pull request 'release: Sanctuary 26.2.0-alpha.207' (#31) from release/26.2.0-alpha.207 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #31
2026-08-28 04:16:16 +00:00
koka 2d286ef54c release: prepare 26.2.0-alpha.207
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 06:03:36 +02:00
koka e599727d26 Merge pull request 'feat(anotherworld): rendre le coffre-fort illimité' (#30) from feature/safe-vault-unlimited-balances into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #30
2026-08-28 03:50:11 +00:00
koka 236d28854e feat(anotherworld): add unlimited safe balances
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 04:53:22 +02:00
koka 1377678e30 Merge pull request 'release: Sanctuary 26.2.0-alpha.206' (#28) from release/26.2.0-alpha.206 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #28
2026-08-27 23:56:40 +00:00
koka 91525a211d release: prepare 26.2.0-alpha.206
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-28 01:02:21 +02:00
koka 702a1d70c3 Merge pull request 'fix(anotherworld): corriger les colonnes CTM du Safe Block' (#27) from fix/safe-vault-ctm-columns into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #27
2026-08-27 22:55:52 +00:00
koka ee5bc3d109 fix(anotherworld): correct safe vault CTM columns
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 23:26:56 +02:00
koka cbf09dfb0e Merge pull request 'release: Sanctuary 26.2.0-alpha.205' (#24) from release/26.2.0-alpha.205 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #24
2026-08-27 16:11:04 +00:00
koka 4fc59802d3 release: prepare 26.2.0-alpha.205
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 18:03:03 +02:00
koka 64a5eca235 Merge pull request 'fix(anotherworld): coffre-fort creux 3×3×3' (#23) from fix/safe-vault-3x3x3 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #23
2026-08-27 15:50:21 +00:00
koka b5a30d294d fix(anotherworld): form hollow 3x3x3 safe vault
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 17:41:25 +02:00
koka 394b163e6b Merge pull request 'release: Sanctuary 26.2.0-alpha.204' (#22) from release/26.2.0-alpha.204 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #22
2026-08-27 14:51:00 +00:00
koka fd3c8bc8d7 release: prepare 26.2.0-alpha.204
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-27 16:39:58 +02:00
koka b215128e08 Merge pull request 'fix(anotherworld): finaliser le Safe Block 3 × 3' (#21) from feature/safe-vault-interface into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #21
2026-08-27 14:25:12 +00:00
415 changed files with 8792 additions and 523 deletions
+60
View File
@@ -0,0 +1,60 @@
# Migration de Canaplia
Canaplia est désormais implémenté dans Sanctuary 26.2 par les tickets [CANA-01 à CANA-09](https://git.botsu.net/koka/sanctuary/issues?state=open). Il reste un namespace de compatibilité réparti entre les propriétaires fonctionnels existants, et non un onzième sous-projet Gradle.
## Architecture
| Responsabilité | Propriétaire | Contrat public |
|---|---|---|
| cultures, variantes, récolte, compostage, résine, effets serveur et Codex | It's Alive | classes itsalive.canaplia, registres historiques canaplia:* |
| calendrier, météo, rendu visuel et accessibilité | Ambiance | AmbianceLunarHarvestApi, AmbianceVisualEffectApi et payloads bornés |
| événements de première découverte | Sanctuary | SanctuaryProgressionService.recordExternal |
| climat et biomes | Another World / It's Alive | ClimateCropForaging |
| exclusions Shop et récompenses aléatoires | Only Fun | OnlyFunItemPolicy |
It's Alive fournit l'alias Fabric canaplia et dépend explicitement d'Another World, d'Ambiance et de Sanctuary. Ambiance dépend de Sanctuary mais jamais d'It's Alive ; aucun cycle n'est introduit.
## Contenu et survie
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 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.
Les hybridations utilisent uniquement des parents matures dans un rayon de un bloc. Le serveur revalide climat, humidité, lumière, eau, horaire, nuit et ciel visible au moment de la récolte et n'effectue jamais plus d'un tirage. La règle Lunar passe par l'instantané public d'Ambiance.
## Effets, rendu et accessibilité
La consommation démarre une session autoritaire serveur de 140 à 180 secondes. Un bad trip éventuel est tiré une seule fois, commence après un délai borné et dure de 25 à 40 secondes. Les rafraîchissements restent bornés ; mort, expiration et déconnexion nettoient la session. L'aura d'une résine ne relance jamais le tirage.
Ambiance possède les trois payloads historiques canaplia:hemp_visual_sync, canaplia:hemp_bad_trip et canaplia:hemp_visual_clear. Il compose un profil normal et un profil de bad trip avec sa météo, sa lightmap et son contrat Iris/Canon, sans shader privé concurrent. Les réglages locaux d'intensité, de réduction des mouvements, de wobble et de ghosting sont disponibles dans les contrôles via « Réglages d'accessibilité visuelle » et n'altèrent jamais le gameplay serveur.
## Codex
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.
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
| Ticket | Fonction |
|---|---|
| [#36 CANA-02](https://git.botsu.net/koka/sanctuary/issues/36) | seize familles, ressources et compostage |
| [#37 CANA-03](https://git.botsu.net/koka/sanctuary/issues/37) | découvertes climatiques data-driven |
| [#38 CANA-04](https://git.botsu.net/koka/sanctuary/issues/38) | hybridations serveur |
| [#39 CANA-05](https://git.botsu.net/koka/sanctuary/issues/39) | Ember Nether et Indigo End |
| [#40 CANA-06](https://git.botsu.net/koka/sanctuary/issues/40) | récolte Lunar liée à Ambiance |
| [#41 CANA-07](https://git.botsu.net/koka/sanctuary/issues/41) | effets serveur et bad trips |
| [#42 CANA-08](https://git.botsu.net/koka/sanctuary/issues/42) | composition visuelle accessible |
| [#43 CANA-09](https://git.botsu.net/koka/sanctuary/issues/43) | Codex persistant personnel et partagé |
## 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` 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. À 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`. 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`.
+100 -2
View File
@@ -6,13 +6,111 @@ 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 — correction finale du Safe Block
## Non distribué
Module modifié : **Another World `0.0.0-alpha.52`**. Le pack distribué reste en **`26.2.0-alpha.203`**.
Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`**, **Ambiance `0.0.0-alpha.15`**, **Only Fun `0.0.0-alpha.24`** et **Ouch `0.0.0-alpha.10`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
- 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.
## `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 ;
- charge les sources de découverte depuis un catalogue data-pack borné, avec variantes sauvages climatiques, Ember limitée au Nether et Indigo limitée à l'End ;
- revalide côté serveur les parents matures, l'humidité, la lumière, l'eau, le climat et l'heure pour six hybridations et la récolte Lunar sous ciel froid et visible ;
- applique seize profils serveur de 140 à 180 secondes avec bad trip unique et borné, nettoyage à l'expiration, à la mort et à la déconnexion ;
- 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`
Module modifié : **Another World `0.0.0-alpha.55`**. Les neuf autres modules conservent leur version issue de lalpha.206.
- remplace les 27 piles du coffre-fort par trois soldes 64 bits séparés pour les rubis, saphirs et émeraudes ;
- migre automatiquement chaque pile existante vers son solde correspondant dans `safe_vaults` v4, sans changer les IDs ni exiger de nouveau monde ;
- synchronise les soldes 64 bits avec linterface et les affiche sous forme compacte au-delà de 999 ;
- conserve laccès du Shop aux monnaies stockées et borne son API historique 32 bits sans limiter le solde réel ;
- crédite directement les 20 % pillés dans le coffre-fort de lattaquant afin d’éviter de générer un nombre non borné dobjets ;
- porte Another World en donnée v5 et protocole réseau 2, puis régénère Packwiz avec le JAR `0.0.0-alpha.55`.
## `26.2.0-alpha.206`
Module modifié : **Another World `0.0.0-alpha.54`**. Les neuf autres modules conservent leur version issue de lalpha.205.
- remet les colonnes A et C dans le bon ordre sur la façade et le dos du coffre-fort 3 × 3 × 3 ;
- conserve la colonne B, les côtés, le dessus, le dessous et les quatre orientations du coffre-fort ;
- régénère Packwiz avec le JAR Another World `0.0.0-alpha.54` et synchronise les métadonnées Prism/Modrinth sur lalpha.206 ;
- ne modifie ni les blockstates formés, ni les données v4, ni `safe_vaults` v3, ni le protocole 1.
## `26.2.0-alpha.205`
Module modifié : **Another World `0.0.0-alpha.53`**. Les neuf autres modules conservent leur version issue de lalpha.204.
- remplace le mur de neuf Safe Blocks par une coque creuse de 26 blocs disposés en 3 × 3 × 3 ;
- laisse le bloc central entièrement libre afin de pouvoir y cacher nimporte quel bloc ;
- refuse les murs 3 × 3, les cubes pleins et toute structure à laquelle un Safe Block supplémentaire est attaché ;
- répartit les textures 48×48 sur chacune des faces extérieures du cube, avec la façade, le dessus, le dessous et les côtés/arrière ;
- conserve les anciennes valeurs de blockstate pour permettre le chargement et le démontage des murs existants, qui doivent être reconstruits en cube ;
- actualise JEI de `30.27.0.188` à `30.27.0.189` dans Packwiz et le manifeste Prism/Modrinth ;
- préserve les comptes, les 27 emplacements de stockage, les soldes et le protocole réseau ; Another World passe à la donnée v4 et `safe_vaults` à la donnée v3.
## `26.2.0-alpha.204`
Module modifié : **Another World `0.0.0-alpha.52`**. Les neuf autres modules conservent leur version issue de lalpha.203.
- charge la texture 16×16 du Safe Block depuis latlas de blocs afin de supprimer la texture manquante avant formation ;
- refuse explicitement les lignes horizontales ou verticales de 3 × 1 et nactive le CTM 48×48 quaprès validation des neuf blocs du mur 3 × 3 ;
- décale le panneau dinventaire étendu sous les trois lignes monétaires afin de rendre l’émeraude visible et laisse Sanctuary afficher un seul libellé dinventaire ;
- actualise JEI de `30.27.0.187` à `30.27.0.188` dans Packwiz et le manifeste Prism/Modrinth ;
- conserve les données v3, le protocole 1, les IDs et les soldes existants sans migration de sauvegarde.
## `26.2.0-alpha.203`
+15 -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`)
@@ -10,8 +10,8 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| Fabric ID | Nom public | Responsabilité | État |
|---|---|---|---|
| `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 et agriculture (20 cultures, vergers, café et vanille) |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, temps réel et calendrier minecraftien |
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
| `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 |
@@ -22,13 +22,23 @@ 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.203`**. Dans le workspace, le correctif du Safe Block porte Another World en développement à **`0.0.0-alpha.52`** sans produire une nouvelle version du pack.
Version courante du pack distribué : **`26.2.0-alpha.210`**. Cette release distribue le Livre interdit Canaplia avec It's Alive **`0.0.0-alpha.35`**, ainsi que Sanctuary **`0.0.0-alpha.118`**, Only Fun **`0.0.0-alpha.21`** et I Like To Move It **`0.0.0-alpha.15`**.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
Lalpha.210 ajoute le Livre interdit, accélère le Happy Ghast piloté, regroupe les corrections du Shop, des requêtes, des capes et des noms de Zombies, puis actualise JEI en **`30.28.0.193`**. Elle met à jour les packs Modrinth, Prism et Packwiz sans exiger de nouveau monde ni réinitialiser les sauvegardes.
Lalpha.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.
Lalpha.206 exige une coque creuse de **3 × 3 × 3**, soit 26 Safe Blocks appartenant au même joueur, et conserve JEI en **`30.27.0.189`**. Le centre est libre et peut contenir un bloc caché. Les quatre images 48×48 se répartissent sur les faces extérieures du cube : façade, dessus, dessous et côtés/arrière. Les colonnes extrêmes de la façade et du dos sont désormais orientées correctement. Un ancien mur 3 × 3 ne sactive plus et doit être démonté puis reconstruit ; les comptes et soldes existants restent conservés.
Lalpha.204 finalise le Safe Block vertical et actualise JEI en **`30.27.0.188`** : sa texture individuelle 16×16 est chargée avant formation, les structures incomplètes 3 × 1 sont refusées et le CTM 48×48 exige les neuf blocs. Linterface monétaire affiche désormais correctement l’émeraude et place linventaire étendu sous les trois lignes de monnaie.
Lalpha.203 porte Another World en **`0.0.0-alpha.51`** et JEI en **`30.27.0.187`**. Le Safe Block forme désormais un mur vertical exact de 3 × 3 : ses blocs restent des cubes individuels avant validation, puis les neuf segments composent les textures connectées 48×48. Son interface affiche trois soldes centrés — rubis, saphirs et émeraudes — avec les transferts serveur ` ×64`, ` ×1`, `+ ×1` et `+ ×64`.
Lalpha.202 permet de descendre par un ascenseur de Waystones vers l’étage immédiatement inférieur encore inconnu, sans ouvrir la montée ni les trajets ordinaires aux destinations non découvertes. Le Shop et le marché noir comptent et consomment désormais les rubis et saphirs de toutes les rangées débloquées, y compris loverflow. Le livre culinaire peut enfin être posé sur un pupitre vanilla pour partager le catalogue de recettes de son propriétaire ; retiré du pupitre, il redevient personnel.
Le coffre-fort joueur utilise neuf blocs Another World disposés en mur vertical de 3 × 3. Il conserve les rubis, saphirs et émeraudes dans un compte lié au joueur et les rend disponibles aux transactions du Shop même lorsque la structure est déchargée. Une foreuse en titane maintenue pendant 60 secondes permet de piller 20 % du contenu dun propriétaire connecté, qui reçoit immédiatement une alerte ; une réussite consomme la foreuse et protège ensuite le coffre pendant 24 heures.
Le coffre-fort joueur utilise 26 blocs Another World disposés en coque creuse de 3 × 3 × 3 autour dun centre libre. Il conserve les rubis, saphirs et émeraudes dans un compte lié au joueur et les rend disponibles aux transactions du Shop même lorsque la structure est déchargée. Une foreuse en titane maintenue pendant 60 secondes permet de piller 20 % du contenu dun propriétaire connecté, qui reçoit immédiatement une alerte ; une réussite consomme la foreuse et protège ensuite le coffre pendant 24 heures.
Lalpha.201 rend les demandes communautaires réellement terminables. Le demandeur dépose la récompense lors de la publication, le preneur confirme sans commande après avoir lu les détails, KILL progresse automatiquement, BRING livre les objets au demandeur et PAY est confirmé par le joueur désigné. La récompense séquestrée est ensuite expédiée une seule fois dans la Mailbox du preneur. Le pseudonyme conserve la couleur du joueur et le panneau rose adopte une palette plus claire et poudrée.
+31
View File
@@ -0,0 +1,31 @@
# 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 youtube-source `1.18.2` (MIT), ainsi que
leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans
les archives dorigine. Laccès YouTube est effectué uniquement par le client et peut cesser de
fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire nest
acceptée par le serveur.
+95 -3
View File
@@ -1,8 +1,51 @@
import groovy.json.JsonSlurper
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:1.18.2") {
exclude group: "org.slf4j", module: "slf4j-api"
exclude group: "org.jetbrains", module: "annotations"
}
[
"dev.arbjerg:lavaplayer-natives:2.2.7",
"dev.arbjerg:lava-common:2.2.7",
"dev.lavalink.youtube:common:1.18.2",
"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.register("ambianceVisualApiSmoke", JavaExec) {
group = "verification"
description = "Checks bounded visual-effect requests exposed to gameplay modules."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualApiSmoke"
}
tasks.register("ambianceScheduleSmoke", JavaExec) {
@@ -13,13 +56,21 @@ 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.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.15, including the bounded YouTube white-disc playback contract."
inputs.files(fileTree("src/main/java"))
inputs.files(fileTree("src/main/resources"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json"))
@@ -60,8 +111,17 @@ 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"))
if (project.version.toString() != "0.0.0-alpha.12"
if (project.version.toString() != "0.0.0-alpha.15"
|| 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"
@@ -76,7 +136,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 != 4
|| 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"
@@ -254,6 +314,36 @@ 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('REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS')
|| !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('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
|| 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('dev.lavalink.youtube:v2:1.18.2')) {
throw new GradleException("Ambiance alpha.15 white-disc playback contract is incomplete")
}
[
'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology',
@@ -304,4 +394,6 @@ tasks.register("verifyAmbiance") {
tasks.named("check") {
dependsOn tasks.named("verifyAmbiance")
dependsOn tasks.named("ambianceScheduleSmoke")
dependsOn tasks.named("ambianceVisualApiSmoke")
dependsOn tasks.named("whiteDiscUrlSmoke")
}
@@ -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,32 @@
package fr.koka99cab.sanctuary26.ambiance.api.environment;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentController;
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentSnapshot;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
/** Server-owned sky/calendar snapshot for dependent harvest rules. */
public final class AmbianceLunarHarvestApi {
public static final int CONTRACT_VERSION = 1;
public record Conditions(
boolean night,
boolean skyVisible,
boolean skyDimension,
AmbianceEnvironmentSnapshot environment
) {
public boolean compatible() {
return night && skyVisible && skyDimension;
}
}
private AmbianceLunarHarvestApi() {}
public static Conditions evaluate(ServerLevel level, BlockPos pos) {
long clock = Math.floorMod(level.getDefaultClockTime(), 24_000L);
boolean night = clock >= 13_000L && clock <= 23_000L;
boolean skyDimension = level.dimensionType().hasSkyLight() && !level.dimensionType().hasFixedTime();
return new Conditions(night, skyDimension && level.canSeeSky(pos.above()), skyDimension,
AmbianceEnvironmentController.snapshot(level.getServer()));
}
}
@@ -0,0 +1,38 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
import java.util.Objects;
import net.minecraft.resources.Identifier;
/**
* Public boundary used by gameplay modules that ask Ambiance to compose a
* temporary visual treatment. Networking and rendering remain Ambiance-owned.
*/
public final class AmbianceVisualEffectApi {
public static final int CONTRACT_VERSION = 1;
public static final int MAX_DURATION_TICKS = 10 * 60 * 20;
private AmbianceVisualEffectApi() {
}
public static Request request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
return new Request(source, profile, remainingTicks);
}
public record Request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
public Request {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(profile, "profile");
remainingTicks = Math.max(0, Math.min(MAX_DURATION_TICKS, remainingTicks));
}
public boolean expired() {
return this.remainingTicks == 0;
}
public Request withReducedMotion(boolean reduceMotion) {
return reduceMotion
? new Request(this.source, this.profile.withoutMotion(), this.remainingTicks)
: this;
}
}
}
@@ -0,0 +1,81 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
/**
* Renderer-independent color and motion parameters composed by Ambiance.
*
* <p>Every value is finite and bounded at construction time so gameplay
* modules cannot send an invasive or unbounded profile to the client.</p>
*/
public record AmbianceVisualProfile(
float intensity,
float tintRed,
float tintGreen,
float tintBlue,
float tintStrength,
float saturationDelta,
float contrastDelta,
float vignetteStrength,
float ghostingStrength,
float wobbleStrength,
float pulseStrength
) {
public static final AmbianceVisualProfile NONE = new AmbianceVisualProfile(
0.0F,
1.0F,
1.0F,
1.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F,
0.0F
);
public AmbianceVisualProfile {
intensity = unit("intensity", intensity);
tintRed = unit("tintRed", tintRed);
tintGreen = unit("tintGreen", tintGreen);
tintBlue = unit("tintBlue", tintBlue);
tintStrength = unit("tintStrength", tintStrength);
saturationDelta = signedUnit("saturationDelta", saturationDelta);
contrastDelta = signedUnit("contrastDelta", contrastDelta);
vignetteStrength = unit("vignetteStrength", vignetteStrength);
ghostingStrength = unit("ghostingStrength", ghostingStrength);
wobbleStrength = unit("wobbleStrength", wobbleStrength);
pulseStrength = unit("pulseStrength", pulseStrength);
}
/** Removes image motion while preserving the bounded color treatment. */
public AmbianceVisualProfile withoutMotion() {
return new AmbianceVisualProfile(
this.intensity,
this.tintRed,
this.tintGreen,
this.tintBlue,
this.tintStrength,
this.saturationDelta,
this.contrastDelta,
this.vignetteStrength,
0.0F,
0.0F,
0.0F
);
}
private static float unit(String field, float value) {
return bounded(field, value, 0.0F, 1.0F);
}
private static float signedUnit(String field, float value) {
return bounded(field, value, -1.0F, 1.0F);
}
private static float bounded(String field, float value, float minimum, float maximum) {
if (!Float.isFinite(value)) {
throw new IllegalArgumentException(field + " must be finite");
}
return Math.max(minimum, Math.min(maximum, value));
}
}
@@ -1,22 +1,35 @@
package fr.koka99cab.sanctuary26.ambiance.client;
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;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderingRegistry;
import net.fabricmc.fabric.api.client.rendering.v1.BlockColorRegistry;
import net.minecraft.client.renderer.block.FluidModel;
import net.minecraft.client.KeyMapping;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.resources.model.sprite.Material;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.Fluids;
import java.util.List;
import org.lwjgl.glfw.GLFW;
public final class AmbianceClient implements ClientModInitializer {
private static final KeyMapping.Category AMBIANCE_CATEGORY =
KeyMapping.Category.register(AmbianceMod.id("ambiance"));
private static KeyMapping visualSettingsKey;
@Override
public void onInitializeClient() {
RealtimeClient.initialize();
@@ -25,10 +38,33 @@ public final class AmbianceClient implements ClientModInitializer {
ClientPlayNetworking.registerGlobalReceiver(AmbianceEnvironmentPayload.TYPE, (payload, context) ->
context.client().execute(() -> AmbianceClientEnvironment.apply(payload.snapshot()))
);
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Normal.ID, (payload, context) ->
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.BadTrip.ID, (payload, context) ->
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));
}
});
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
AmbianceClientEnvironment.reset();
DynamicTorchLights.reset();
AmbianceClientVisualEffects.clear();
WhiteDiscAudioPlayer.clear();
});
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown());
AmbianceVisualOverlay.register();
AmbianceBiomeTints.registerColorResolvers();
registerWaterFluidTint();
BlockColorRegistry.register(List.of(AmbianceBiomeTints.GRASS),
@@ -0,0 +1,74 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
/** Single normal plus single bad-trip layer, composed and expired by Ambiance. */
public final class AmbianceClientVisualEffects {
public enum Mode { NONE, NORMAL, BAD_TRIP }
private static AmbianceVisualAccessibility accessibility = AmbianceVisualAccessibility.load();
private static AmbianceVisualProfile normal = AmbianceVisualProfile.NONE;
private static AmbianceVisualProfile badTrip = AmbianceVisualProfile.NONE;
private static int normalTicks;
private static int badTripTicks;
private AmbianceClientVisualEffects() {}
public static void apply(AmbianceCanapliaVisualPayloads.Normal payload) {
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
normal = payload.profile();
normalTicks = payload.remainingTicks();
}
public static void apply(AmbianceCanapliaVisualPayloads.BadTrip payload) {
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
badTrip = payload.profile();
badTripTicks = payload.remainingTicks();
}
public static void clear() {
normal = AmbianceVisualProfile.NONE;
badTrip = AmbianceVisualProfile.NONE;
normalTicks = 0;
badTripTicks = 0;
}
public static void tick() {
if (normalTicks > 0 && --normalTicks == 0) normal = AmbianceVisualProfile.NONE;
if (badTripTicks > 0 && --badTripTicks == 0) badTrip = AmbianceVisualProfile.NONE;
}
public static Mode mode() {
return badTripTicks > 0 ? Mode.BAD_TRIP : normalTicks > 0 ? Mode.NORMAL : Mode.NONE;
}
public static AmbianceVisualProfile current() {
return accessible(mode() == Mode.BAD_TRIP ? badTrip
: mode() == Mode.NORMAL ? normal : AmbianceVisualProfile.NONE);
}
public static AmbianceVisualAccessibility accessibility() {
return accessibility;
}
public static void updateAccessibility(AmbianceVisualAccessibility updated) {
accessibility = updated == null ? AmbianceVisualAccessibility.defaults() : updated;
accessibility.save();
}
private static AmbianceVisualProfile accessible(AmbianceVisualProfile profile) {
float scale = accessibility.intensity();
boolean noMotion = accessibility.reduceMotion();
return new AmbianceVisualProfile(
profile.intensity() * scale,
profile.tintRed(), profile.tintGreen(), profile.tintBlue(),
profile.tintStrength() * scale,
profile.saturationDelta() * scale,
profile.contrastDelta() * scale,
profile.vignetteStrength() * scale,
noMotion || !accessibility.ghostingEnabled() ? 0.0F : profile.ghostingStrength() * scale,
noMotion || !accessibility.wobbleEnabled() ? 0.0F : profile.wobbleStrength() * scale,
noMotion ? 0.0F : profile.pulseStrength() * scale
);
}
}
@@ -54,6 +54,7 @@ public final class AmbianceEffects {
Vector4f color = fogData.color;
// Keep the deeper beta-like night fog without turning the horizon into a black wall.
setAdjustedColor(color, 1.18F, Mth.lerp(night, 1.0F, 0.98F));
applyTemporaryProfile(color);
}
public static void adjustLightmap(LightmapRenderState state, float tickDelta) {
@@ -75,6 +76,8 @@ public final class AmbianceEffects {
state.blockLightTint = warmBlockLight(state.blockLightTint, night);
state.blockFactor *= Mth.lerp(night, 1.0F, 1.08F);
state.brightness = Math.min(state.brightness, Mth.lerp(night, 0.58F, 0.16F));
state.ambientColor = applyTemporaryProfile(state.ambientColor);
state.skyLightColor = applyTemporaryProfile(state.skyLightColor);
}
private static void applyWhiteNightLight(LightmapRenderState state, float night) {
@@ -128,4 +131,23 @@ public final class AmbianceEffects {
Mth.lerp(warmth, saturated.z(), 0.56F)
);
}
private static void applyTemporaryProfile(Vector4f color) {
Vector3f adjusted = applyTemporaryProfile(new Vector3f(color.x(), color.y(), color.z()));
color.set(adjusted.x(), adjusted.y(), adjusted.z(), color.w());
}
private static Vector3f applyTemporaryProfile(Vector3fc source) {
var profile = AmbianceClientVisualEffects.current();
float intensity = profile.intensity();
if (intensity <= 0.0F) return new Vector3f(source);
Vector3f graded = adjustedColor(source, 1.0F + profile.saturationDelta() * intensity,
1.0F + profile.contrastDelta() * intensity * 0.35F);
float tint = profile.tintStrength() * intensity * 0.28F;
return new Vector3f(
Mth.lerp(tint, graded.x(), profile.tintRed()),
Mth.lerp(tint, graded.y(), profile.tintGreen()),
Mth.lerp(tint, graded.z(), profile.tintBlue())
);
}
}
@@ -0,0 +1,58 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import net.fabricmc.loader.api.FabricLoader;
/** Local presentation-only settings; they can never affect server gameplay state. */
public record AmbianceVisualAccessibility(float intensity, boolean reduceMotion,
boolean wobbleEnabled, boolean ghostingEnabled) {
private static final Gson GSON = new Gson();
public AmbianceVisualAccessibility {
intensity = Float.isFinite(intensity) ? Math.clamp(intensity, 0.0F, 1.0F) : 1.0F;
}
public static AmbianceVisualAccessibility load() {
try {
Path file = configPath();
if (!Files.isRegularFile(file)) return defaults();
JsonObject json = GSON.fromJson(Files.readString(file), JsonObject.class);
if (json == null) return defaults();
return new AmbianceVisualAccessibility(
json.has("intensity") ? json.get("intensity").getAsFloat() : 1.0F,
json.has("reduce_motion") && json.get("reduce_motion").getAsBoolean(),
!json.has("wobble") || json.get("wobble").getAsBoolean(),
!json.has("ghosting") || json.get("ghosting").getAsBoolean());
} catch (IOException | RuntimeException ignored) {
return defaults();
}
}
public void save() {
JsonObject json = new JsonObject();
json.addProperty("intensity", intensity);
json.addProperty("reduce_motion", reduceMotion);
json.addProperty("wobble", wobbleEnabled);
json.addProperty("ghosting", ghostingEnabled);
try {
Path file = configPath();
Files.createDirectories(file.getParent());
Files.writeString(file, GSON.toJson(json) + System.lineSeparator());
} catch (IOException | RuntimeException exception) {
AmbianceMod.LOGGER.warn("[Ambiance] Could not save visual accessibility settings.", exception);
}
}
public static AmbianceVisualAccessibility defaults() {
return new AmbianceVisualAccessibility(1.0F, false, true, true);
}
private static Path configPath() {
return FabricLoader.getInstance().getConfigDir().resolve("ambiance-visual.json");
}
}
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.gui.GuiGraphicsExtractor;
/** Lightweight final-frame tint/vignette; no competing post-processing owner is introduced. */
public final class AmbianceVisualOverlay {
private AmbianceVisualOverlay() {}
public static void register() {
HudElementRegistry.addLast(AmbianceMod.id("temporary_visual_effects"), AmbianceVisualOverlay::render);
}
private static void render(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
if (profile.intensity() <= 0.0F) return;
int width = graphics.guiWidth();
int height = graphics.guiHeight();
int alpha = Math.clamp(Math.round(255.0F * profile.intensity() * profile.tintStrength() * 0.32F), 0, 80);
int rgb = (Math.round(profile.tintRed() * 255.0F) << 16)
| (Math.round(profile.tintGreen() * 255.0F) << 8)
| Math.round(profile.tintBlue() * 255.0F);
graphics.fill(0, 0, width, height, (alpha << 24) | rgb);
int depth = Math.max(8, Math.min(width, height) / 12);
int edgeAlpha = Math.clamp(Math.round(190.0F * profile.vignetteStrength() * profile.intensity()), 0, 150);
for (int layer = 0; layer < 4; layer++) {
int outer = layer * depth / 4;
int inner = (layer + 1) * depth / 4;
int color = ((edgeAlpha * (4 - layer) / 4) << 24) | (rgb & 0x7F7F7F);
graphics.fill(outer, outer, width - outer, inner, color);
graphics.fill(outer, height - inner, width - outer, height - outer, color);
graphics.fill(outer, inner, inner, height - inner, color);
graphics.fill(width - inner, inner, width - outer, height - inner, color);
}
if (profile.ghostingStrength() > 0.0F) {
int ghostAlpha = Math.clamp(Math.round(profile.ghostingStrength() * 45.0F), 0, 45);
graphics.fill(2, 0, 5, height, (ghostAlpha << 24) | rgb);
graphics.fill(width - 7, 0, width - 3, height, (ghostAlpha << 24) | rgb);
}
}
}
@@ -0,0 +1,72 @@
package fr.koka99cab.sanctuary26.ambiance.client;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** Small in-game editor for the local presentation-only Canaplia visual settings. */
public final class AmbianceVisualSettingsScreen extends Screen {
private final Screen parent;
public AmbianceVisualSettingsScreen(Screen parent) {
super(Component.translatable("options.ambiance.visual.title"));
this.parent = parent;
}
@Override
protected void init() {
AmbianceVisualAccessibility settings = AmbianceClientVisualEffects.accessibility();
int left = width / 2 - 120;
int y = height / 2 - 62;
addRenderableWidget(Button.builder(value("options.ambiance.visual.intensity",
Math.round(settings.intensity() * 100.0F) + "%"), button -> {
float next = settings.intensity() <= 0.0F ? 1.0F : settings.intensity() - 0.25F;
update(new AmbianceVisualAccessibility(next, settings.reduceMotion(),
settings.wobbleEnabled(), settings.ghostingEnabled()));
}).bounds(left, y, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.reduce_motion", settings.reduceMotion()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), !settings.reduceMotion(),
settings.wobbleEnabled(), settings.ghostingEnabled())))
.bounds(left, y + 24, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.wobble", settings.wobbleEnabled()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
!settings.wobbleEnabled(), settings.ghostingEnabled())))
.bounds(left, y + 48, 240, 20).build());
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.ghosting", settings.ghostingEnabled()),
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
settings.wobbleEnabled(), !settings.ghostingEnabled())))
.bounds(left, y + 72, 240, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
.bounds(left + 44, y + 108, 152, 20).build());
}
private void update(AmbianceVisualAccessibility settings) {
AmbianceClientVisualEffects.updateAccessibility(settings);
rebuildWidgets();
}
private static Component toggle(String key, boolean enabled) {
return value(key, Component.translatable(enabled ? "options.on" : "options.off"));
}
private static Component value(String key, String value) {
return value(key, Component.literal(value));
}
private static Component value(String key, Component value) {
return Component.translatable(key).append(": ").append(value);
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
graphics.fill(width / 2 - 142, height / 2 - 92, width / 2 + 142, height / 2 + 92, 0xE0161C19);
graphics.centeredText(font, title, width / 2, height / 2 - 82, 0xFFE4EAD9);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
@Override
public void onClose() {
if (minecraft != null) minecraft.gui.setScreen(parent);
}
}
@@ -0,0 +1,87 @@
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 int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = 2;
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 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;
continue;
}
AudioFrame frame = nextFrame();
if (frame == null) {
if (ended) break;
int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE);
for (int index = 0; index < silence; index++) output.put((byte) 0);
continue;
}
if (frame.isTerminator()) {
ended = true;
break;
}
pending = frame.getData();
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
output.flip();
return output;
}
private AudioFrame nextFrame() throws IOException {
try {
AudioFrame frame = player.provide(2, TimeUnit.SECONDS);
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;
}
@Override
public void close() {
if (closed) return;
closed = true;
player.destroy();
}
}
@@ -0,0 +1,213 @@
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.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
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 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;
private static final DefaultAudioPlayerManager MANAGER = createManager();
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) || !validTarget(client, pos)) return;
long key = pos.asLong();
if (REQUESTS.containsKey(key)) return;
boolean replacingPlayback = PLAYBACKS.containsKey(key);
if (!replacingPlayback
&& REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) return;
stop(key);
long request = ++requestSequence;
REQUESTS.put(key, request);
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
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) {
if (handle != null) handle.release();
return;
}
AudioPlayer player = MANAGER.createPlayer();
player.playTrack(track);
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream);
PLAYBACKS.put(key, playback);
REQUESTS.remove(key);
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
handle.execute(channel -> {
channel.setSelfPosition(Vec3.atCenterOf(pos));
channel.setRelative(false);
channel.linearAttenuation(ATTENUATION_DISTANCE);
channel.setVolume(volume);
channel.attachBufferStream(stream);
channel.play();
});
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"));
}
public static void stop(BlockPos pos) {
stop(pos.asLong());
}
private static void stop(long key) {
REQUESTS.remove(key);
Playback playback = PLAYBACKS.remove(key);
if (playback == null) return;
playback.stream().close();
playback.handle().execute(channel -> channel.stop());
playback.handle().release();
}
public static void tick(Minecraft client) {
if (client.level == null) {
clear();
return;
}
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.setVolume(volume));
}
}
public static void 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));
return manager;
}
private static boolean validTarget(Minecraft client, BlockPos pos) {
return client.player != null && client.level != null
&& client.player.distanceToSqr(Vec3.atCenterOf(pos))
<= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX)
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
}
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,30 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
import net.minecraft.client.Camera;
import net.minecraft.client.DeltaTracker;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Bounded motion is applied on the central Ambiance camera path and disappears with the profile. */
@Mixin(Camera.class)
public abstract class CameraMixin {
@Shadow public abstract float yaw();
@Shadow public abstract float xRot();
@Shadow protected abstract void setRotation(float yaw, float pitch);
@Inject(method = "update", at = @At("TAIL"))
private void ambiance$applyTemporaryMotion(DeltaTracker deltaTracker, CallbackInfo ci) {
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
if (profile.wobbleStrength() <= 0.0F && profile.pulseStrength() <= 0.0F) return;
double time = System.nanoTime() / 1_000_000_000.0D;
float yawOffset = (float) Math.sin(time * 2.1D) * profile.wobbleStrength() * 1.8F;
float pitchOffset = (float) Math.cos(time * 1.7D) *
(profile.wobbleStrength() + profile.pulseStrength() * 0.5F) * 1.2F;
setRotation(yaw() + yawOffset, xRot() + pitchOffset);
}
}
@@ -0,0 +1,19 @@
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(LevelRenderer.class)
public abstract class LevelRendererMixin {
@Inject(method = "levelEvent", at = @At("HEAD"))
private void ambiance$stopWhiteDisc(Entity source, 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();
}
@@ -0,0 +1,108 @@
package fr.koka99cab.sanctuary26.ambiance.network;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualEffectApi;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Ambiance-owned, versioned implementation of the three historical Canaplia payload IDs. */
public final class AmbianceCanapliaVisualPayloads {
public static final int PROTOCOL_VERSION = 1;
private static final Identifier NORMAL_ID = id("hemp_visual_sync");
private static final Identifier BAD_TRIP_ID = id("hemp_bad_trip");
private static final Identifier CLEAR_ID = id("hemp_visual_clear");
private AmbianceCanapliaVisualPayloads() {}
public record Normal(int protocol, AmbianceVisualProfile profile, int remainingTicks)
implements CustomPacketPayload {
public static final Type<Normal> ID = new Type<>(NORMAL_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, Normal> CODEC =
CustomPacketPayload.codec(Normal::write, Normal::new);
public Normal {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(NORMAL_ID, profile, remainingTicks);
profile = request.profile();
remainingTicks = request.remainingTicks();
}
private Normal(RegistryFriendlyByteBuf buffer) {
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(protocol);
writeProfile(buffer, profile);
buffer.writeVarInt(remainingTicks);
}
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
public record BadTrip(int protocol, AmbianceVisualProfile profile, int remainingTicks)
implements CustomPacketPayload {
public static final Type<BadTrip> ID = new Type<>(BAD_TRIP_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, BadTrip> CODEC =
CustomPacketPayload.codec(BadTrip::write, BadTrip::new);
public BadTrip {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(BAD_TRIP_ID, profile, remainingTicks);
profile = request.profile();
remainingTicks = request.remainingTicks();
}
private BadTrip(RegistryFriendlyByteBuf buffer) {
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(protocol);
writeProfile(buffer, profile);
buffer.writeVarInt(remainingTicks);
}
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
public record Clear(int protocol) implements CustomPacketPayload {
public static final Type<Clear> ID = new Type<>(CLEAR_ID);
public static final StreamCodec<RegistryFriendlyByteBuf, Clear> CODEC =
CustomPacketPayload.codec(Clear::write, Clear::new);
public Clear {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
}
private Clear(RegistryFriendlyByteBuf buffer) { this(buffer.readVarInt()); }
private void write(RegistryFriendlyByteBuf buffer) { buffer.writeVarInt(protocol); }
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
}
private static AmbianceVisualProfile readProfile(RegistryFriendlyByteBuf buffer) {
return new AmbianceVisualProfile(buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
buffer.readFloat(), buffer.readFloat(), buffer.readFloat());
}
private static void writeProfile(RegistryFriendlyByteBuf buffer, AmbianceVisualProfile profile) {
buffer.writeFloat(profile.intensity());
buffer.writeFloat(profile.tintRed());
buffer.writeFloat(profile.tintGreen());
buffer.writeFloat(profile.tintBlue());
buffer.writeFloat(profile.tintStrength());
buffer.writeFloat(profile.saturationDelta());
buffer.writeFloat(profile.contrastDelta());
buffer.writeFloat(profile.vignetteStrength());
buffer.writeFloat(profile.ghostingStrength());
buffer.writeFloat(profile.wobbleStrength());
buffer.writeFloat(profile.pulseStrength());
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath("canaplia", path);
}
}
@@ -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 = 4;
public static final int PROTOCOL_VERSION = 6;
private static boolean initialized;
private AmbianceNetworking() {
@@ -28,6 +28,14 @@ public final class AmbianceNetworking {
AmbianceEnvironmentPayload.TYPE,
AmbianceEnvironmentPayload.CODEC
);
PayloadTypeRegistry.clientboundPlay().register(
AmbianceCanapliaVisualPayloads.Normal.ID, AmbianceCanapliaVisualPayloads.Normal.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
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();
}
}
@@ -4,12 +4,16 @@
"package": "fr.koka99cab.sanctuary26.ambiance.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"CameraMixin",
"DynamicEntityRendererMixin",
"DynamicItemInHandRendererMixin",
"FogRendererMixin",
"GameRendererMixin",
"LightCoordsUtilMixin",
"LightmapRenderStateExtractorMixin",
"LevelRendererMixin",
"SoundEngineAccessor",
"SoundManagerAccessor",
"SkyRendererMixin"
],
"injectors": {
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "ambiance:item/white_disc"
}
}
@@ -17,5 +17,20 @@
"command.ambiance.weather.set_day": "Weather set to %s until the next day.",
"command.ambiance.weather.set_duration": "Weather set to %1$s for %2$s ticks.",
"command.ambiance.weather.status": "Current weather: %s.",
"command.ambiance.weather.auto": "Automatic daily weather restored: %s."
"command.ambiance.weather.auto": "Automatic daily weather restored: %s.",
"options.ambiance.visual.title": "Temporary visual effects",
"options.ambiance.visual.intensity": "Effect intensity",
"options.ambiance.visual.reduce_motion": "Reduce motion",
"options.ambiance.visual.wobble": "Camera wobble",
"options.ambiance.visual.ghosting": "Edge ghosting",
"key.categories.ambiance.ambiance": "Ambiance",
"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."
}
@@ -17,5 +17,20 @@
"command.ambiance.weather.set_day": "Météo réglée sur %s jusqu'au prochain jour.",
"command.ambiance.weather.set_duration": "Météo réglée sur %1$s pendant %2$s ticks.",
"command.ambiance.weather.status": "Météo actuelle : %s.",
"command.ambiance.weather.auto": "Météo quotidienne automatique restaurée : %s."
"command.ambiance.weather.auto": "Météo quotidienne automatique restaurée : %s.",
"options.ambiance.visual.title": "Effets visuels temporaires",
"options.ambiance.visual.intensity": "Intensité des effets",
"options.ambiance.visual.reduce_motion": "Réduire les mouvements",
"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",
"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."
}
@@ -17,5 +17,20 @@
"command.ambiance.weather.set_day": "Погода установлена: %s до следующего дня.",
"command.ambiance.weather.set_duration": "Погода установлена: %1$s на %2$s тиков.",
"command.ambiance.weather.status": "Текущая погода: %s.",
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s."
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s.",
"options.ambiance.visual.title": "Временные визуальные эффекты",
"options.ambiance.visual.intensity": "Интенсивность эффектов",
"options.ambiance.visual.reduce_motion": "Уменьшить движение",
"options.ambiance.visual.wobble": "Покачивание камеры",
"options.ambiance.visual.ghosting": "Шлейф по краям",
"key.categories.ambiance.ambiance": "Ambiance",
"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 минут."
}
@@ -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 held lights, real time and the Minecraft calendar.",
"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": 4,
"network_protocol": 6,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, le temps réel et le calendrier minecraftien.",
"en_us": "Owns world atmosphere, dynamic lights, real time and the Minecraft calendar.",
"ru_ru": "Управляет атмосферой, динамическим освещением, реальным временем и календарём Minecraft."
"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,89 @@
package fr.koka99cab.sanctuary26.ambiance.api.visual;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceVisualAccessibility;
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
import net.minecraft.resources.Identifier;
public final class AmbianceVisualApiSmoke {
private AmbianceVisualApiSmoke() {
}
public static void main(String[] arguments) {
AmbianceVisualProfile bounded = new AmbianceVisualProfile(
2.0F,
-1.0F,
0.5F,
4.0F,
2.0F,
-3.0F,
3.0F,
2.0F,
0.4F,
0.3F,
0.2F
);
assertEquals(1.0F, bounded.intensity(), "intensity upper bound");
assertEquals(0.0F, bounded.tintRed(), "red lower bound");
assertEquals(1.0F, bounded.tintBlue(), "blue upper bound");
assertEquals(-1.0F, bounded.saturationDelta(), "saturation lower bound");
assertEquals(1.0F, bounded.contrastDelta(), "contrast upper bound");
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(
Identifier.fromNamespaceAndPath("canaplia", "hemp_visual_sync"),
bounded,
Integer.MAX_VALUE
);
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, request.remainingTicks(), "duration bound");
AmbianceVisualProfile reduced = request.withReducedMotion(true).profile();
assertEquals(0.0F, reduced.ghostingStrength(), "reduced ghosting");
assertEquals(0.0F, reduced.wobbleStrength(), "reduced wobble");
assertEquals(0.0F, reduced.pulseStrength(), "reduced pulse");
AmbianceCanapliaVisualPayloads.Normal normal = new AmbianceCanapliaVisualPayloads.Normal(
Integer.MAX_VALUE, bounded, Integer.MAX_VALUE);
assertEquals(AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, normal.protocol(), "payload protocol bound");
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, normal.remainingTicks(), "payload duration bound");
AmbianceCanapliaVisualPayloads.BadTrip badTrip = new AmbianceCanapliaVisualPayloads.BadTrip(
Integer.MAX_VALUE, bounded, -4);
assertEquals(0, badTrip.remainingTicks(), "negative payload duration bound");
AmbianceClientVisualEffects.apply(normal);
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NORMAL,
"normal profile active");
AmbianceClientVisualEffects.apply(new AmbianceCanapliaVisualPayloads.BadTrip(
AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, bounded, 20));
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.BAD_TRIP,
"bad-trip profile precedence");
AmbianceClientVisualEffects.clear();
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NONE,
"profile cleanup");
AmbianceVisualAccessibility accessibility = new AmbianceVisualAccessibility(
2.0F, true, true, true);
assertEquals(1.0F, accessibility.intensity(), "accessibility intensity bound");
try {
new AmbianceVisualProfile(
Float.NaN, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F
);
throw new AssertionError("non-finite profile value accepted");
} catch (IllegalArgumentException expected) {
// Expected.
}
}
private static void assertEquals(float expected, float actual, String label) {
if (Float.compare(expected, actual) != 0) {
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
}
}
private static void assertTrue(boolean condition, String label) {
if (!condition) throw new AssertionError(label);
}
private static void assertEquals(int expected, int actual, String label) {
if (expected != actual) {
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
}
}
}
@@ -0,0 +1,40 @@
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(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);
}
}
+38 -23
View File
@@ -9,7 +9,7 @@ sourceSets.main.resources.srcDir(generatedResources)
tasks.register("vaultRulesSmoke", JavaExec) {
group = "verification"
description = "Checks the 3x3 vault, one-minute raid, 20% loot and 24-hour protection rules."
description = "Checks the hollow 3x3x3 vault, one-minute raid, 20% loot and 24-hour protection rules."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.anotherworld.vault.VaultRulesSmoke"
@@ -177,13 +177,24 @@ tasks.register("generateAnotherWorldResources") {
// Mirror, creative-only rainbow blocks, gems and first-pass metal art.
simpleBlock("mirror", "minecraft:block/cube_all", "anotherworld:block/mirror_glass")
def vaultParts = [
def vaultFaceParts = [
[id: "bottom_left", column: 0, row: 0], [id: "bottom_center", column: 1, row: 0],
[id: "bottom_right", column: 2, row: 0], [id: "middle_left", column: 0, row: 1],
[id: "center", column: 1, row: 1], [id: "middle_right", column: 2, row: 1],
[id: "top_left", column: 0, row: 2], [id: "top_center", column: 1, row: 2],
[id: "top_right", column: 2, row: 2]
]
def vaultParts = []
[[prefix: "back", depth: 0], [prefix: null, depth: 1], [prefix: "front", depth: 2]].each { layer ->
vaultFaceParts.each { facePart ->
vaultParts.add([
id: layer.prefix == null ? facePart.id : "${layer.prefix}_${facePart.id}",
column: facePart.column,
row: facePart.row,
depth: layer.depth
])
}
}
def safeMultipart = [[when: [formed: "false"], apply: [model: "anotherworld:block/safe_block_unformed"]]]
[north: 0, east: 90, south: 180, west: 270].each { facing, rotation ->
vaultParts.each { part ->
@@ -201,10 +212,14 @@ tasks.register("generateAnotherWorldResources") {
[column * third, (2 - row) * third, (column + 1) * third, (3 - row) * third]
}
vaultParts.each { part ->
def frontUv = segmentUv(part.column as int, part.row as int)
def backUv = segmentUv(2 - (part.column as int), part.row as int)
def sideUv = segmentUv(1, part.row as int)
def topUv = segmentUv(part.column as int, 1)
// Structure columns run screen-right on the front face. Mirror them there,
// while the same axis is already screen-left when viewed from the back.
def frontUv = segmentUv(2 - (part.column as int), part.row as int)
def backUv = segmentUv(part.column as int, part.row as int)
def westUv = segmentUv(2 - (part.depth as int), part.row as int)
def eastUv = segmentUv(part.depth as int, part.row as int)
def topUv = segmentUv(part.column as int, part.depth as int)
def bottomUv = segmentUv(part.column as int, 2 - (part.depth as int))
writeJson("assets/anotherworld/models/block/safe_block/${part.id}.json", [
textures: [
particle: "anotherworld:block/safe_vault/front",
@@ -214,12 +229,12 @@ tasks.register("generateAnotherWorldResources") {
side: "anotherworld:block/safe_vault/side"
],
elements: [[from: [0, 0, 0], to: [16, 16, 16], faces: [
down: [uv: topUv, texture: "#bottom", cullface: "down"],
down: [uv: bottomUv, texture: "#bottom", cullface: "down"],
up: [uv: topUv, texture: "#top", cullface: "up"],
north: [uv: frontUv, texture: "#front", cullface: "north"],
south: [uv: backUv, texture: "#side", cullface: "south"],
west: [uv: sideUv, texture: "#side", cullface: "west"],
east: [uv: sideUv, texture: "#side", cullface: "east"]
west: [uv: westUv, texture: "#side", cullface: "west"],
east: [uv: eastUv, texture: "#side", cullface: "east"]
]]]
])
}
@@ -1622,10 +1637,10 @@ tasks.register("generateAnotherWorldResources") {
"container.anotherworld.safe_vault": "Coffre-fort",
"tooltip.anotherworld.titanium_drill.use": "Maintenir sur un coffre-fort ennemi pour le forer.",
"tooltip.anotherworld.titanium_drill.consumed": "Consommée après un pillage réussi.",
"message.anotherworld.safe_vault.incomplete": "Le coffre-fort exige exactement 9 blocs appartenant au même joueur, posés en mur vertical de 3 × 3.",
"message.anotherworld.safe_vault.incomplete": "Le coffre-fort exige exactement 26 blocs appartenant au même joueur, disposés en coque creuse de 3 × 3 × 3 avec un centre libre.",
"message.anotherworld.safe_vault.locked": "Ce coffre-fort appartient à un autre joueur.",
"message.anotherworld.safe_vault.formed": "Coffre-fort vertical 3 × 3 lié à vous en %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Impossible de déposer %s : inventaire vide ou coffre-fort plein.",
"message.anotherworld.safe_vault.formed": "Coffre-fort creux 3 × 3 × 3 lié à vous en %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Impossible de déposer %s : aucun exemplaire disponible dans votre inventaire.",
"message.anotherworld.safe_vault.withdraw_failed": "Impossible de retirer %s : solde vide ou inventaire plein.",
"message.anotherworld.safe_vault.already_owned": "Vous possédez déjà un coffre-fort actif.",
"message.anotherworld.safe_vault.break_not_owner": "Seul le propriétaire peut démonter ce bloc de coffre-fort.",
@@ -1641,15 +1656,15 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Forage : %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Forage interrompu.",
"message.anotherworld.safe_vault.raided_owner": "%s a pillé %s gemme(s) dans votre coffre-fort.",
"message.anotherworld.safe_vault.raided": "Pillage réussi : %s gemme(s) cupérée(s)."
"message.anotherworld.safe_vault.raided": "Pillage réussi : %s gemme(s) cditée(s) dans votre coffre-fort."
] : locale == "ru_ru" ? [
"container.anotherworld.safe_vault": "Сейф",
"tooltip.anotherworld.titanium_drill.use": "Удерживайте на чужом сейфе, чтобы просверлить его.",
"tooltip.anotherworld.titanium_drill.consumed": "Расходуется после успешного ограбления.",
"message.anotherworld.safe_vault.incomplete": "Сейф требует вертикальную стену 3 × 3 из 9 блоков одного владельца.",
"message.anotherworld.safe_vault.incomplete": "Сейф требует полую оболочку 3 × 3 × 3 из 26 блоков одного владельца со свободным центром.",
"message.anotherworld.safe_vault.locked": "Этот сейф принадлежит другому игроку.",
"message.anotherworld.safe_vault.formed": "Вертикальный сейф 3 × 3 привязан к вам в точке %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Не удалось внести %s: предметов нет или сейф заполнен.",
"message.anotherworld.safe_vault.formed": "Полый сейф 3 × 3 × 3 привязан к вам в точке %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Не удалось внести %s: в инвентаре нет доступных предметов.",
"message.anotherworld.safe_vault.withdraw_failed": "Не удалось забрать %s: баланс пуст или инвентарь заполнен.",
"message.anotherworld.safe_vault.already_owned": "У вас уже есть активный сейф.",
"message.anotherworld.safe_vault.break_not_owner": "Разобрать этот блок может только владелец.",
@@ -1665,15 +1680,15 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Сверление: %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Сверление прервано.",
"message.anotherworld.safe_vault.raided_owner": "%s похитил(а) %s драгоценностей из вашего сейфа.",
"message.anotherworld.safe_vault.raided": "Ограбление удалось: получено %s драгоценностей."
"message.anotherworld.safe_vault.raided": "Ограбление удалось: %s драгоценностей зачислено в ваш сейф."
] : [
"container.anotherworld.safe_vault": "Safe",
"tooltip.anotherworld.titanium_drill.use": "Hold on an enemy safe to drill it.",
"tooltip.anotherworld.titanium_drill.consumed": "Consumed after a successful raid.",
"message.anotherworld.safe_vault.incomplete": "A safe requires an upright 3 × 3 wall of exactly 9 blocks owned by one player.",
"message.anotherworld.safe_vault.incomplete": "A safe requires a hollow 3 × 3 × 3 shell of exactly 26 blocks owned by one player, with a free center.",
"message.anotherworld.safe_vault.locked": "This safe belongs to another player.",
"message.anotherworld.safe_vault.formed": "Upright 3 × 3 safe linked to you at %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Could not deposit %s: none available or the safe is full.",
"message.anotherworld.safe_vault.formed": "Hollow 3 × 3 × 3 safe linked to you at %s, %s, %s.",
"message.anotherworld.safe_vault.deposit_failed": "Could not deposit %s: none are available in your inventory.",
"message.anotherworld.safe_vault.withdraw_failed": "Could not withdraw %s: the balance is empty or inventory is full.",
"message.anotherworld.safe_vault.already_owned": "You already own an active safe.",
"message.anotherworld.safe_vault.break_not_owner": "Only the owner may dismantle this safe block.",
@@ -1689,7 +1704,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.drilling_progress": "Drilling: %s %%",
"message.anotherworld.safe_vault.drilling_cancelled": "Drilling interrupted.",
"message.anotherworld.safe_vault.raided_owner": "%s stole %s gem item(s) from your safe.",
"message.anotherworld.safe_vault.raided": "Raid successful: recovered %s gem item(s)."
"message.anotherworld.safe_vault.raided": "Raid successful: %s gem item(s) credited to your safe."
]
lang.putAll(vaultText)
def tagTranslations = locale == "fr_fr" ? [
@@ -1938,7 +1953,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.52"
if (project.version.toString() != "0.0.0-alpha.55"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3255,7 +3270,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.52"
if (project.version.toString() != "0.0.0-alpha.55"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -108,7 +108,7 @@ public final class SafeVaultBlock extends BaseEntityBlock {
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.locked"));
return InteractionResult.CONSUME;
}
if (level.getBlockEntity(formed.center()) instanceof SafeVaultBlockEntity controller) {
if (level.getBlockEntity(formed.controller()) instanceof SafeVaultBlockEntity controller) {
player.openMenu(controller);
return InteractionResult.SUCCESS_SERVER;
}
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.anotherworld.block.entity;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlockEntities;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultData;
import fr.koka99cab.sanctuary26.anotherworld.vault.SafeVaultMenu;
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultStructure;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.UUIDUtil;
@@ -54,7 +55,10 @@ public final class SafeVaultBlockEntity extends BlockEntity implements MenuProvi
@Override
public AbstractContainerMenu createMenu(int containerId, Inventory inventory, Player player) {
if (owner == null || !owner.equals(player.getUUID()) || !(level instanceof ServerLevel serverLevel)) return null;
VaultStructure.Formed formed = VaultStructure.find(serverLevel, getBlockPos())
.filter(candidate -> candidate.owner().equals(owner)).orElse(null);
if (formed == null || !formed.controller().equals(getBlockPos())) return null;
return new SafeVaultMenu(containerId, inventory, VaultData.get(serverLevel.getServer()),
serverLevel, getBlockPos(), owner);
serverLevel, formed.center(), owner);
}
}
@@ -53,7 +53,7 @@ public final class SafeVaultScreen extends AbstractContainerScreen<SafeVaultMenu
graphics.outline(x, y, 18, 18, 0xFF373737);
graphics.fill(x + 1, y + 1, x + 17, y + 17, 0xFF8B8B8B);
graphics.item(new ItemStack(SafeVaultMenu.currency(currency)), x + 1, y + 1);
Component balance = Component.literal(Integer.toString(menu.balance(currency)));
Component balance = Component.literal(VaultRules.formatBalance(menu.balance(currency)));
graphics.text(font, balance, x + 17 - font.width(balance), y + 9, 0xFFFFFFFF, true);
}
}
@@ -34,7 +34,7 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private final BlockPos center;
public SafeVaultMenu(int containerId, Inventory inventory) {
this(containerId, inventory, new SimpleContainerData(VaultRules.CURRENCY_SLOTS),
this(containerId, inventory, new SimpleContainerData(VaultRules.BALANCE_DATA_SLOTS),
null, null, null, null);
}
@@ -47,7 +47,7 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private SafeVaultMenu(int containerId, Inventory inventory, ContainerData balances,
VaultData vaultData, ServerLevel serverLevel, BlockPos center, UUID owner) {
super(AnotherWorldMenus.SAFE_VAULT, containerId);
checkContainerDataCount(balances, VaultRules.CURRENCY_SLOTS);
checkContainerDataCount(balances, VaultRules.BALANCE_DATA_SLOTS);
this.balances = balances;
this.vaultData = vaultData;
this.serverLevel = serverLevel;
@@ -58,8 +58,10 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
addDataSlots(balances);
}
public int balance(int currency) {
return currency >= 0 && currency < VaultRules.CURRENCY_SLOTS ? balances.get(currency) : 0;
public long balance(int currency) {
if (currency < 0 || currency >= VaultRules.CURRENCY_SLOTS) return 0L;
int base = currency * VaultRules.BALANCE_WORDS_PER_CURRENCY;
return VaultRules.balanceFromWords(balances.get(base), balances.get(base + 1));
}
public static Item currency(int currency) {
@@ -104,22 +106,22 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
return false;
}
return vaultData.isRegistered(serverLevel, center, owner)
&& VaultStructure.find(serverLevel, center)
&& VaultStructure.findAtCenter(serverLevel, center)
.filter(formed -> formed.center().equals(center) && formed.owner().equals(owner)).isPresent();
}
private int deposit(ServerPlayer player, Item item, int requested) {
int available = countPlayerItem(player, item);
int amount = Math.min(requested, Math.min(available, vaultData.capacity(owner, item)));
int amount = Math.min(requested, available);
if (amount <= 0) return 0;
int removed = removePlayerItem(player, item, amount);
int stored = vaultData.add(owner, item, removed);
int stored = (int) vaultData.add(owner, item, removed);
if (stored < removed) insertPlayerItem(player, item, removed - stored);
return stored;
}
private int withdraw(ServerPlayer player, Item item, int requested) {
int amount = Math.min(requested,
int amount = (int) Math.min(requested,
Math.min(vaultData.count(owner, item), playerCapacity(player, item)));
if (amount <= 0) return 0;
int removed = vaultData.remove(owner, item, amount);
@@ -184,12 +186,15 @@ public final class SafeVaultMenu extends AbstractContainerMenu {
private record VaultBalanceData(VaultData data, UUID owner) implements ContainerData {
@Override public int get(int index) {
return index >= 0 && index < CURRENCIES.size() ? data.count(owner, CURRENCIES.get(index)) : 0;
if (index < 0 || index >= VaultRules.BALANCE_DATA_SLOTS) return 0;
int currency = index / VaultRules.BALANCE_WORDS_PER_CURRENCY;
int word = index % VaultRules.BALANCE_WORDS_PER_CURRENCY;
return VaultRules.balanceWord(data.count(owner, CURRENCIES.get(currency)), word);
}
@Override public void set(int index, int value) {
}
@Override public int getCount() {
return VaultRules.CURRENCY_SLOTS;
return VaultRules.BALANCE_DATA_SLOTS;
}
}
}
@@ -9,28 +9,27 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Global player-linked vault inventory, available to the Shop even when its structure is unloaded. */
/** Global player-linked unlimited currency balances, available even when the vault is unloaded. */
public final class VaultData extends SavedData {
public static final String SAVE_KEY = "safe_vaults";
public static final int DATA_VERSION = 2;
private static final Identifier RUBY = Identifier.fromNamespaceAndPath("anotherworld", "ruby");
private static final Identifier SAPPHIRE = Identifier.fromNamespaceAndPath("anotherworld", "sapphire");
private static final Identifier EMERALD = Identifier.withDefaultNamespace("emerald");
public static final int DATA_VERSION = 4;
private static final List<Identifier> CURRENCIES = List.of(
Identifier.fromNamespaceAndPath("anotherworld", "ruby"),
Identifier.fromNamespaceAndPath("anotherworld", "sapphire"),
Identifier.withDefaultNamespace("emerald")
);
/** Legacy v3 slot format, retained only so existing worlds migrate monotonically. */
private static final Codec<StoredSlot> SLOT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("slot").forGetter(StoredSlot::slot),
ItemStack.CODEC.fieldOf("item").forGetter(StoredSlot::item)
@@ -38,6 +37,9 @@ public final class VaultData extends SavedData {
private static final Codec<StoredAccount> ACCOUNT_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("owner").forGetter(StoredAccount::owner),
SLOT_CODEC.listOf().optionalFieldOf("items", List.of()).forGetter(StoredAccount::items),
Codec.LONG.optionalFieldOf("rubies", 0L).forGetter(StoredAccount::rubies),
Codec.LONG.optionalFieldOf("sapphires", 0L).forGetter(StoredAccount::sapphires),
Codec.LONG.optionalFieldOf("emeralds", 0L).forGetter(StoredAccount::emeralds),
Codec.LONG.optionalFieldOf("protected_until", 0L).forGetter(StoredAccount::protectedUntil)
).apply(instance, StoredAccount::new));
private static final Codec<VaultAddress> ADDRESS_CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -66,13 +68,13 @@ public final class VaultData extends SavedData {
for (StoredAccount stored : accounts == null ? List.<StoredAccount>of() : accounts) {
try {
UUID owner = UUID.fromString(stored.owner());
NonNullList<ItemStack> items = NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY);
Account account = new Account(stored.rubies(), stored.sapphires(), stored.emeralds(),
stored.protectedUntil());
for (StoredSlot slot : stored.items()) {
if (slot.slot() >= 0 && slot.slot() < items.size() && isCurrency(slot.item())) {
items.set(slot.slot(), slot.item().copy());
}
int currency = currencyIndex(slot.item());
if (currency >= 0) account.add(currency, slot.item().getCount());
}
this.accounts.put(owner, new Account(items, Math.max(0L, stored.protectedUntil())));
this.accounts.put(owner, account);
} catch (IllegalArgumentException ignored) {
AnotherWorldMod.LOGGER.warn("[Vault] Ignored account with invalid owner {}.", stored.owner());
}
@@ -123,86 +125,59 @@ public final class VaultData extends SavedData {
if (changed) setDirty();
}
public Container container(UUID owner, ServerLevel level, BlockPos center) {
accounts.computeIfAbsent(owner, ignored -> new Account());
return new VaultContainer(this, owner, level, center.immutable());
public long count(UUID owner, Item item) {
int currency = currencyIndex(item);
Account account = accounts.get(owner);
return currency < 0 || account == null ? 0L : account.balance(currency);
}
public int count(UUID owner, Item item) {
if (!isCurrency(item)) return 0;
Account account = accounts.get(owner);
if (account == null) return 0;
long count = 0L;
for (ItemStack stack : account.items) if (stack.is(item)) count += stack.getCount();
return (int) Math.min(Integer.MAX_VALUE, count);
public int countCapped(UUID owner, Item item) {
return (int) Math.min(Integer.MAX_VALUE, count(owner, item));
}
public int remove(UUID owner, Item item, int amount) {
if (!isCurrency(item) || amount <= 0) return 0;
return (int) remove(owner, item, (long) amount);
}
public long remove(UUID owner, Item item, long amount) {
int currency = currencyIndex(item);
Account account = accounts.get(owner);
if (account == null) return 0;
int remaining = amount;
for (ItemStack stack : account.items) {
if (remaining <= 0) break;
if (!stack.is(item)) continue;
int removed = Math.min(remaining, stack.getCount());
stack.shrink(removed);
remaining -= removed;
}
if (remaining != amount) setDirty();
return amount - remaining;
if (currency < 0 || account == null || amount <= 0L) return 0L;
long removed = account.remove(currency, amount);
if (removed > 0L) setDirty();
return removed;
}
public int capacity(UUID owner, Item item) {
if (!isCurrency(item)) return 0;
long capacity = 0L;
for (ItemStack stack : items(owner)) {
if (stack.isEmpty()) capacity += item.getDefaultMaxStackSize();
else if (stack.is(item)) capacity += Math.max(0, stack.getMaxStackSize() - stack.getCount());
}
return (int) Math.min(Integer.MAX_VALUE, capacity);
}
public int add(UUID owner, Item item, int amount) {
if (!isCurrency(item) || amount <= 0) return 0;
NonNullList<ItemStack> stored = items(owner);
int remaining = amount;
for (ItemStack stack : stored) {
if (remaining <= 0) break;
if (!stack.is(item) || stack.getCount() >= stack.getMaxStackSize()) continue;
int inserted = Math.min(remaining, stack.getMaxStackSize() - stack.getCount());
stack.grow(inserted);
remaining -= inserted;
}
for (int slot = 0; slot < stored.size() && remaining > 0; slot++) {
if (!stored.get(slot).isEmpty()) continue;
int inserted = Math.min(remaining, item.getDefaultMaxStackSize());
stored.set(slot, new ItemStack(item, inserted));
remaining -= inserted;
}
if (remaining != amount) setDirty();
return amount - remaining;
public long add(UUID owner, Item item, long amount) {
int currency = currencyIndex(item);
if (currency < 0 || amount <= 0L) return 0L;
long added = accounts.computeIfAbsent(owner, ignored -> new Account()).add(currency, amount);
if (added > 0L) setDirty();
return added;
}
public boolean hasContents(UUID owner) {
Account account = accounts.get(owner);
return account != null && account.items.stream().anyMatch(stack -> !stack.isEmpty());
return account != null && account.hasContents();
}
public List<ItemStack> raidLoot(UUID owner) {
List<ItemStack> loot = new ArrayList<>();
for (Identifier id : List.of(RUBY, SAPPHIRE, EMERALD)) {
Item item = BuiltInRegistries.ITEM.getOptional(id).orElse(null);
if (item == null) continue;
int amount = VaultRules.raidLoot(count(owner, item));
int removed = remove(owner, item, amount);
while (removed > 0) {
int stackSize = Math.min(removed, item.getDefaultMaxStackSize());
loot.add(new ItemStack(item, stackSize));
removed -= stackSize;
}
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
public long transferRaid(UUID owner, UUID attacker) {
if (owner == null || attacker == null || owner.equals(attacker)) return 0L;
Account source = accounts.get(owner);
if (source == null) return 0L;
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
long total = 0L;
for (int currency = 0; currency < VaultRules.CURRENCY_SLOTS; currency++) {
long requested = VaultRules.raidLoot(source.balance(currency));
long accepted = Math.min(requested, VaultRules.MAX_BALANCE - target.balance(currency));
long removed = source.remove(currency, accepted);
long added = target.add(currency, removed);
if (added < removed) source.add(currency, removed - added);
total = VaultRules.saturatingAdd(total, added);
}
return List.copyOf(loot);
if (total > 0L) setDirty();
return total;
}
public long protectionRemaining(UUID owner, long gameTime) {
@@ -217,90 +192,74 @@ public final class VaultData extends SavedData {
}
public static boolean isCurrency(ItemStack stack) {
return stack != null && !stack.isEmpty() && isCurrency(stack.getItem());
return stack != null && !stack.isEmpty() && currencyIndex(stack) >= 0;
}
public static boolean isCurrency(Item item) {
if (item == null) return false;
Identifier id = BuiltInRegistries.ITEM.getKey(item);
return RUBY.equals(id) || SAPPHIRE.equals(id) || EMERALD.equals(id);
}
private NonNullList<ItemStack> items(UUID owner) {
return accounts.computeIfAbsent(owner, ignored -> new Account()).items;
return currencyIndex(item) >= 0;
}
private List<StoredAccount> storedAccounts() {
List<StoredAccount> stored = new ArrayList<>();
accounts.forEach((owner, account) -> {
List<StoredSlot> slots = new ArrayList<>();
for (int index = 0; index < account.items.size(); index++) {
ItemStack stack = account.items.get(index);
if (!stack.isEmpty()) slots.add(new StoredSlot(index, stack));
}
stored.add(new StoredAccount(owner.toString(), List.copyOf(slots), account.protectedUntil));
});
accounts.forEach((owner, account) -> stored.add(new StoredAccount(owner.toString(), List.of(),
account.balance(0), account.balance(1), account.balance(2), account.protectedUntil)));
return List.copyOf(stored);
}
private static int currencyIndex(ItemStack stack) {
return stack == null || stack.isEmpty() ? -1 : currencyIndex(stack.getItem());
}
private static int currencyIndex(Item item) {
if (item == null) return -1;
return CURRENCIES.indexOf(BuiltInRegistries.ITEM.getKey(item));
}
private static String dimension(ServerLevel level) {
return level.dimension().identifier().toString();
}
private static final class Account {
private final NonNullList<ItemStack> items;
private final long[] balances = new long[VaultRules.CURRENCY_SLOTS];
private long protectedUntil;
private Account() { this(NonNullList.withSize(VaultRules.STORAGE_SLOTS, ItemStack.EMPTY), 0L); }
private Account(NonNullList<ItemStack> items, long protectedUntil) {
this.items = items;
this.protectedUntil = protectedUntil;
}
}
private static final class VaultContainer implements Container {
private final VaultData data;
private final UUID owner;
private final ServerLevel level;
private final BlockPos center;
private VaultContainer(VaultData data, UUID owner, ServerLevel level, BlockPos center) {
this.data = data;
this.owner = owner;
this.level = level;
this.center = center;
private Account() {
this(0L, 0L, 0L, 0L);
}
@Override public int getContainerSize() { return VaultRules.STORAGE_SLOTS; }
@Override public boolean isEmpty() { return data.items(owner).stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return data.items(owner).get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(data.items(owner), slot, amount);
if (!removed.isEmpty()) setChanged();
private Account(long rubies, long sapphires, long emeralds, long protectedUntil) {
balances[0] = Math.max(0L, rubies);
balances[1] = Math.max(0L, sapphires);
balances[2] = Math.max(0L, emeralds);
this.protectedUntil = Math.max(0L, protectedUntil);
}
private long balance(int currency) {
return balances[currency];
}
private long add(int currency, long amount) {
if (amount <= 0L) return 0L;
long previous = balances[currency];
balances[currency] = VaultRules.saturatingAdd(previous, amount);
return balances[currency] - previous;
}
private long remove(int currency, long amount) {
if (amount <= 0L) return 0L;
long removed = Math.min(amount, balances[currency]);
balances[currency] -= removed;
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(data.items(owner), slot);
if (!removed.isEmpty()) setChanged();
return removed;
private boolean hasContents() {
for (long balance : balances) if (balance > 0L) return true;
return false;
}
@Override public void setItem(int slot, ItemStack stack) {
if (stack != null && !stack.isEmpty() && !isCurrency(stack)) return;
data.items(owner).set(slot, stack == null ? ItemStack.EMPTY : stack);
setChanged();
}
@Override public void setChanged() { data.setDirty(); }
@Override public boolean stillValid(Player player) {
if (player == null || !player.getUUID().equals(owner)
|| player.distanceToSqr(center.getX() + 0.5D, center.getY() + 0.5D, center.getZ() + 0.5D) > 64.0D)
return false;
return data.isRegistered(level, center, owner)
&& VaultStructure.find(level, center).filter(formed -> formed.center().equals(center)).isPresent();
}
@Override public void clearContent() { data.items(owner).clear(); setChanged(); }
@Override public boolean canPlaceItem(int slot, ItemStack stack) { return isCurrency(stack); }
}
private record StoredSlot(int slot, ItemStack item) {}
private record StoredAccount(String owner, List<StoredSlot> items, long protectedUntil) {}
private record StoredAccount(String owner, List<StoredSlot> items, long rubies, long sapphires,
long emeralds, long protectedUntil) {}
private record VaultAddress(String dimension, long pos, String owner) {}
}
@@ -3,7 +3,6 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
@@ -125,14 +124,12 @@ public final class VaultDrillingService {
private static void complete(ServerPlayer attacker, ServerLevel level, Session session,
VaultStructure.Formed formed) {
VaultData data = VaultData.get(level.getServer());
List<ItemStack> loot = data.raidLoot(session.owner());
if (loot.isEmpty()) {
long total = data.transferRaid(session.owner(), attacker.getUUID());
if (total <= 0L) {
cancel(attacker, true);
attacker.stopUsingItem();
return;
}
int total = loot.stream().mapToInt(ItemStack::getCount).sum();
loot.forEach(stack -> attacker.getInventory().placeItemBackInInventory(stack));
data.protectAfterRaid(session.owner(), level.getServer().overworld().getGameTime());
ItemStack drill = attacker.getItemInHand(session.hand());
if (!attacker.hasInfiniteMaterials()) drill.shrink(1);
@@ -2,26 +2,46 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
import net.minecraft.util.StringRepresentable;
/** Stable position of one segment in the front-facing 3x3 vault wall. */
/** Stable position of one segment in the front-facing 3x3x3 vault cube. */
public enum VaultPart implements StringRepresentable {
BOTTOM_LEFT("bottom_left", 0, 0),
BOTTOM_CENTER("bottom_center", 1, 0),
BOTTOM_RIGHT("bottom_right", 2, 0),
MIDDLE_LEFT("middle_left", 0, 1),
CENTER("center", 1, 1),
MIDDLE_RIGHT("middle_right", 2, 1),
TOP_LEFT("top_left", 0, 2),
TOP_CENTER("top_center", 1, 2),
TOP_RIGHT("top_right", 2, 2);
BACK_BOTTOM_LEFT("back_bottom_left", 0, 0, 0),
BACK_BOTTOM_CENTER("back_bottom_center", 1, 0, 0),
BACK_BOTTOM_RIGHT("back_bottom_right", 2, 0, 0),
BACK_MIDDLE_LEFT("back_middle_left", 0, 1, 0),
BACK_CENTER("back_center", 1, 1, 0),
BACK_MIDDLE_RIGHT("back_middle_right", 2, 1, 0),
BACK_TOP_LEFT("back_top_left", 0, 2, 0),
BACK_TOP_CENTER("back_top_center", 1, 2, 0),
BACK_TOP_RIGHT("back_top_right", 2, 2, 0),
BOTTOM_LEFT("bottom_left", 0, 0, 1),
BOTTOM_CENTER("bottom_center", 1, 0, 1),
BOTTOM_RIGHT("bottom_right", 2, 0, 1),
MIDDLE_LEFT("middle_left", 0, 1, 1),
CENTER("center", 1, 1, 1),
MIDDLE_RIGHT("middle_right", 2, 1, 1),
TOP_LEFT("top_left", 0, 2, 1),
TOP_CENTER("top_center", 1, 2, 1),
TOP_RIGHT("top_right", 2, 2, 1),
FRONT_BOTTOM_LEFT("front_bottom_left", 0, 0, 2),
FRONT_BOTTOM_CENTER("front_bottom_center", 1, 0, 2),
FRONT_BOTTOM_RIGHT("front_bottom_right", 2, 0, 2),
FRONT_MIDDLE_LEFT("front_middle_left", 0, 1, 2),
FRONT_CENTER("front_center", 1, 1, 2),
FRONT_MIDDLE_RIGHT("front_middle_right", 2, 1, 2),
FRONT_TOP_LEFT("front_top_left", 0, 2, 2),
FRONT_TOP_CENTER("front_top_center", 1, 2, 2),
FRONT_TOP_RIGHT("front_top_right", 2, 2, 2);
private final String serializedName;
private final int column;
private final int row;
private final int depth;
VaultPart(String serializedName, int column, int row) {
VaultPart(String serializedName, int column, int row, int depth) {
this.serializedName = serializedName;
this.column = column;
this.row = row;
this.depth = depth;
}
public int column() {
@@ -32,11 +52,15 @@ public enum VaultPart implements StringRepresentable {
return row;
}
public static VaultPart at(int column, int row) {
public int depth() {
return depth;
}
public static VaultPart at(int column, int row, int depth) {
for (VaultPart part : values()) {
if (part.column == column && part.row == row) return part;
if (part.column == column && part.row == row && part.depth == depth) return part;
}
throw new IllegalArgumentException("Invalid vault part: " + column + ", " + row);
throw new IllegalArgumentException("Invalid vault part: " + column + ", " + row + ", " + depth);
}
@Override
@@ -3,12 +3,19 @@ package fr.koka99cab.sanctuary26.anotherworld.vault;
/** Pure balancing rules shared by runtime code and smoke tests. */
public final class VaultRules {
public static final int STRUCTURE_WIDTH = 3;
public static final int STRUCTURE_DEPTH = 3;
public static final int STRUCTURE_RADIUS = 1;
public static final int STRUCTURE_BLOCKS = STRUCTURE_WIDTH * STRUCTURE_WIDTH;
public static final int COMPLETE_STRUCTURE_MASK = (1 << STRUCTURE_BLOCKS) - 1;
public static final int STORAGE_SLOTS = 27;
public static final int STRUCTURE_POSITIONS = STRUCTURE_WIDTH * STRUCTURE_WIDTH * STRUCTURE_DEPTH;
public static final int STRUCTURE_BLOCKS = STRUCTURE_POSITIONS - 1;
public static final int CENTER_PART_INDEX = STRUCTURE_RADIUS * STRUCTURE_WIDTH * STRUCTURE_WIDTH
+ STRUCTURE_RADIUS * STRUCTURE_WIDTH + STRUCTURE_RADIUS;
public static final int FULL_STRUCTURE_MASK = (1 << STRUCTURE_POSITIONS) - 1;
public static final int COMPLETE_STRUCTURE_MASK = FULL_STRUCTURE_MASK & ~(1 << CENTER_PART_INDEX);
public static final int CURRENCY_SLOTS = 3;
public static final int BALANCE_WORDS_PER_CURRENCY = 2;
public static final int BALANCE_DATA_SLOTS = CURRENCY_SLOTS * BALANCE_WORDS_PER_CURRENCY;
public static final int BUTTONS_PER_CURRENCY = 4;
public static final long MAX_BALANCE = Long.MAX_VALUE;
public static final int RAID_DURATION_TICKS = 60 * 20;
public static final int RAID_LOOT_PERCENT = 20;
public static final long RAID_PROTECTION_TICKS = 24L * 60L * 60L * 20L;
@@ -16,10 +23,47 @@ public final class VaultRules {
private VaultRules() {
}
public static int raidLoot(int stored) {
public static long raidLoot(long stored) {
if (stored <= 0) return 0;
long rounded = ((long) stored * RAID_LOOT_PERCENT + 99L) / 100L;
return (int) Math.min(stored, Math.max(1L, rounded));
long rounded = stored / 100L * RAID_LOOT_PERCENT
+ (stored % 100L * RAID_LOOT_PERCENT + 99L) / 100L;
return Math.min(stored, Math.max(1L, rounded));
}
public static long saturatingAdd(long current, long added) {
if (current < 0L || added <= 0L) return Math.max(0L, current);
return added > MAX_BALANCE - current ? MAX_BALANCE : current + added;
}
public static int balanceWord(long balance, int word) {
long safe = Math.clamp(balance, 0L, MAX_BALANCE);
return switch (word) {
case 0 -> (int) safe;
case 1 -> (int) (safe >>> 32);
default -> 0;
};
}
public static long balanceFromWords(int low, int high) {
return Math.max(0L, ((long) high << 32) | Integer.toUnsignedLong(low));
}
public static String formatBalance(long balance) {
long safe = Math.max(0L, balance);
if (safe < 1_000L) return Long.toString(safe);
long[] units = {1_000L, 1_000_000L, 1_000_000_000L, 1_000_000_000_000L,
1_000_000_000_000_000L};
String[] suffixes = {"K", "M", "B", "T", "Q"};
int unitIndex = units.length - 1;
while (unitIndex > 0 && safe < units[unitIndex]) unitIndex--;
long unit = units[unitIndex];
long whole = safe / unit;
if (whole >= 1_000L) return safe / 1_000_000_000_000_000_000L + "E18";
if (whole < 10L) {
long tenth = safe % unit * 10L / unit;
if (tenth > 0L) return whole + "." + tenth + suffixes[unitIndex];
}
return whole + suffixes[unitIndex];
}
public static int progressPercent(int elapsedTicks) {
@@ -30,6 +74,14 @@ public final class VaultRules {
return partMask == COMPLETE_STRUCTURE_MASK;
}
public static int partIndex(int column, int row, int depth) {
if (column < 0 || column >= STRUCTURE_WIDTH || row < 0 || row >= STRUCTURE_WIDTH
|| depth < 0 || depth >= STRUCTURE_DEPTH) {
throw new IllegalArgumentException("Invalid vault coordinates: " + column + ", " + row + ", " + depth);
}
return depth * STRUCTURE_WIDTH * STRUCTURE_WIDTH + row * STRUCTURE_WIDTH + column;
}
public static int buttonCurrency(int buttonId) {
return validButton(buttonId) ? buttonId / BUTTONS_PER_CURRENCY : -1;
}
@@ -26,7 +26,7 @@ public final class VaultService {
initialized = true;
SanctuaryCurrencyStorage.register(AnotherWorldMod.id("safe_vault"), new SanctuaryCurrencyStorage.Provider() {
@Override public int count(ServerPlayer player, net.minecraft.world.item.Item item) {
return VaultData.get(player.level().getServer()).count(player.getUUID(), item);
return VaultData.get(player.level().getServer()).countCapped(player.getUUID(), item);
}
@Override public int remove(ServerPlayer player, net.minecraft.world.item.Item item, int amount) {
return VaultData.get(player.level().getServer()).remove(player.getUUID(), item, amount);
@@ -61,15 +61,18 @@ public final class VaultService {
net.minecraft.core.BlockPos center = VaultStructure.centerFor(pos, state);
var facing = state.getValue(SafeVaultBlock.FACING);
var width = facing.getClockWise();
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
var member = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS);
BlockState memberState = level.getBlockState(member);
if (!memberState.is(AnotherWorldBlocks.SAFE_BLOCK)) continue;
level.setBlock(member, memberState
.setValue(SafeVaultBlock.FORMED, false)
.setValue(SafeVaultBlock.PART, VaultPart.CENTER), Block.UPDATE_CLIENTS);
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
var member = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS)
.relative(facing, depth - VaultRules.STRUCTURE_RADIUS);
BlockState memberState = level.getBlockState(member);
if (!memberState.is(AnotherWorldBlocks.SAFE_BLOCK)) continue;
level.setBlock(member, memberState
.setValue(SafeVaultBlock.FORMED, false)
.setValue(SafeVaultBlock.PART, VaultPart.CENTER), Block.UPDATE_CLIENTS);
}
}
}
}
@@ -12,7 +12,7 @@ import net.minecraft.core.Direction;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.Level;
/** Detects one isolated, upright 3x3 vault wall owned by a single player. */
/** Detects one isolated, hollow 3x3x3 vault cube owned by a single player. */
public final class VaultStructure {
private VaultStructure() {
}
@@ -29,41 +29,66 @@ public final class VaultStructure {
offsetWidth <= VaultRules.STRUCTURE_RADIUS; offsetWidth++) {
for (int offsetY = -VaultRules.STRUCTURE_RADIUS;
offsetY <= VaultRules.STRUCTURE_RADIUS; offsetY++) {
BlockPos center = member.relative(width, -offsetWidth).below(offsetY);
Formed formed = validate(level, center, facing);
if (formed != null && formed.contains(member)) return Optional.of(formed);
for (int offsetDepth = -VaultRules.STRUCTURE_RADIUS;
offsetDepth <= VaultRules.STRUCTURE_RADIUS; offsetDepth++) {
BlockPos center = member.relative(width, -offsetWidth).below(offsetY)
.relative(facing, -offsetDepth);
Formed formed = validate(level, center, facing);
if (formed != null && formed.contains(member)) return Optional.of(formed);
}
}
}
}
return Optional.empty();
}
public static Optional<Formed> findAtCenter(Level level, BlockPos center) {
if (level == null || center == null) return Optional.empty();
for (Direction direction : Direction.Plane.HORIZONTAL) {
Optional<Formed> formed = find(level, center.relative(direction))
.filter(candidate -> candidate.center().equals(center));
if (formed.isPresent()) return formed;
}
return Optional.empty();
}
private static Formed validate(Level level, BlockPos center, Direction facing) {
UUID owner = null;
List<BlockPos> members = new ArrayList<>(VaultRules.STRUCTURE_BLOCKS);
int partMask = 0;
Direction width = facing.getClockWise();
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
BlockPos pos = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS);
if (!level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)
|| !(level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity)
|| blockEntity.owner() == null) return null;
if (owner == null) owner = blockEntity.owner();
else if (!owner.equals(blockEntity.owner())) return null;
members.add(pos.immutable());
partMask |= 1 << (row * VaultRules.STRUCTURE_WIDTH + column);
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
BlockPos pos = center.relative(width, column - VaultRules.STRUCTURE_RADIUS)
.above(row - VaultRules.STRUCTURE_RADIUS)
.relative(facing, depth - VaultRules.STRUCTURE_RADIUS);
if (column == VaultRules.STRUCTURE_RADIUS && row == VaultRules.STRUCTURE_RADIUS
&& depth == VaultRules.STRUCTURE_RADIUS) {
if (level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
continue;
}
if (!level.getBlockState(pos).is(AnotherWorldBlocks.SAFE_BLOCK)
|| !(level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity)
|| blockEntity.owner() == null) return null;
if (owner == null) owner = blockEntity.owner();
else if (!owner.equals(blockEntity.owner())) return null;
members.add(pos.immutable());
partMask |= 1 << VaultRules.partIndex(column, row, depth);
}
}
}
if (!VaultRules.isCompleteStructureMask(partMask)) return null;
// Reject ambiguous 3x4/4x3 walls: a vault is one discrete upright 3x3 structure.
// Reject attached Safe Blocks: a vault is one discrete 3x3x3 cube.
for (int widthOffset = -2; widthOffset <= 2; widthOffset++) {
for (int yOffset = -2; yOffset <= 2; yOffset++) {
if (Math.abs(widthOffset) <= 1 && Math.abs(yOffset) <= 1) continue;
if (level.getBlockState(center.relative(width, widthOffset).above(yOffset))
.is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
for (int depthOffset = -2; depthOffset <= 2; depthOffset++) {
if (Math.abs(widthOffset) <= 1 && Math.abs(yOffset) <= 1
&& Math.abs(depthOffset) <= 1) continue;
if (level.getBlockState(center.relative(width, widthOffset).above(yOffset)
.relative(facing, depthOffset)).is(AnotherWorldBlocks.SAFE_BLOCK)) return null;
}
}
}
return new Formed(center.immutable(), owner, List.copyOf(members), facing);
@@ -73,7 +98,9 @@ public final class VaultStructure {
VaultPart part = state.getValue(SafeVaultBlock.PART);
Direction width = state.getValue(SafeVaultBlock.FACING).getClockWise();
return member.relative(width, VaultRules.STRUCTURE_RADIUS - part.column())
.below(part.row() - VaultRules.STRUCTURE_RADIUS).immutable();
.below(part.row() - VaultRules.STRUCTURE_RADIUS)
.relative(state.getValue(SafeVaultBlock.FACING), VaultRules.STRUCTURE_RADIUS - part.depth())
.immutable();
}
public record Formed(BlockPos center, UUID owner, List<BlockPos> members, Direction facing) {
@@ -86,7 +113,13 @@ public final class VaultStructure {
int column = (pos.getX() - center.getX()) * width.getStepX()
+ (pos.getZ() - center.getZ()) * width.getStepZ() + VaultRules.STRUCTURE_RADIUS;
int row = pos.getY() - center.getY() + VaultRules.STRUCTURE_RADIUS;
return VaultPart.at(column, row);
int depth = (pos.getX() - center.getX()) * facing.getStepX()
+ (pos.getZ() - center.getZ()) * facing.getStepZ() + VaultRules.STRUCTURE_RADIUS;
return VaultPart.at(column, row, depth);
}
public BlockPos controller() {
return center.relative(facing, VaultRules.STRUCTURE_RADIUS).immutable();
}
}
}
@@ -37,8 +37,8 @@
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "anotherworld",
"data_version": 3,
"network_protocol": 1,
"data_version": 5,
"network_protocol": 2,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
@@ -6,18 +6,37 @@ public final class VaultRulesSmoke {
public static void main(String[] args) {
require(VaultRules.STRUCTURE_WIDTH == 3, "vault width");
require(VaultRules.STRUCTURE_BLOCKS == 9, "upright vault wall");
require(!VaultRules.isCompleteStructureMask(0b000_000_111),
"horizontal 3x1 must stay unformed");
require(!VaultRules.isCompleteStructureMask(0b001_001_001),
"vertical 3x1 must stay unformed");
require(VaultRules.isCompleteStructureMask(0b111_111_111),
"only all nine parts form the vault");
require(VaultRules.STORAGE_SLOTS == 27, "vault capacity");
require(VaultRules.STRUCTURE_DEPTH == 3, "vault depth");
require(VaultRules.STRUCTURE_POSITIONS == 27, "vault cube positions");
require(VaultRules.STRUCTURE_BLOCKS == 26, "hollow vault shell");
require(VaultRules.CENTER_PART_INDEX == VaultRules.partIndex(1, 1, 1), "free center index");
require(Integer.bitCount(VaultRules.COMPLETE_STRUCTURE_MASK) == VaultRules.STRUCTURE_BLOCKS,
"26 required shell positions");
require(!VaultRules.isCompleteStructureMask((1 << 9) - 1),
"3x3 wall must stay unformed");
require(!VaultRules.isCompleteStructureMask(VaultRules.FULL_STRUCTURE_MASK),
"solid cube must stay unformed");
require(VaultRules.isCompleteStructureMask(VaultRules.COMPLETE_STRUCTURE_MASK),
"only the complete 26-block shell forms the vault");
require(VaultRules.CURRENCY_SLOTS == 3, "currency controls");
require(VaultPart.at(0, 0) == VaultPart.BOTTOM_LEFT, "bottom-left CTM part");
require(VaultPart.at(1, 1) == VaultPart.CENTER, "center CTM part");
require(VaultPart.at(2, 2) == VaultPart.TOP_RIGHT, "top-right CTM part");
require(VaultRules.BALANCE_DATA_SLOTS == 6, "64-bit balance synchronization");
require(VaultRules.MAX_BALANCE == Long.MAX_VALUE, "unlimited practical balance");
require(VaultPart.at(0, 0, 0) == VaultPart.BACK_BOTTOM_LEFT, "back-bottom-left CTM part");
require(VaultPart.at(1, 1, 1) == VaultPart.CENTER, "center CTM part");
require(VaultPart.at(2, 2, 2) == VaultPart.FRONT_TOP_RIGHT, "front-top-right CTM part");
boolean[] seenParts = new boolean[VaultRules.STRUCTURE_POSITIONS];
for (int depth = 0; depth < VaultRules.STRUCTURE_DEPTH; depth++) {
for (int row = 0; row < VaultRules.STRUCTURE_WIDTH; row++) {
for (int column = 0; column < VaultRules.STRUCTURE_WIDTH; column++) {
int index = VaultRules.partIndex(column, row, depth);
require(!seenParts[index], "unique cube part index");
seenParts[index] = true;
VaultPart part = VaultPart.at(column, row, depth);
require(part.column() == column && part.row() == row && part.depth() == depth,
"cube part coordinates");
}
}
}
require(VaultRules.buttonCurrency(0) == 0 && VaultRules.buttonCurrency(11) == 2,
"button currency routing");
require(VaultRules.buttonAmount(0) == 64 && VaultRules.buttonAmount(1) == 1
@@ -33,7 +52,18 @@ public final class VaultRulesSmoke {
require(VaultRules.raidLoot(1) == 1, "minimum loot");
require(VaultRules.raidLoot(5) == 1, "exact 20 percent");
require(VaultRules.raidLoot(64) == 13, "rounded 20 percent");
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730, "bounded large balance");
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
"64-bit balance raid without overflow");
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
"balance saturation without overflow");
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
require(VaultRules.balanceFromWords(VaultRules.balanceWord(balance, 0),
VaultRules.balanceWord(balance, 1)) == balance, "64-bit balance synchronization round trip");
}
require(VaultRules.formatBalance(999L).equals("999"), "small balance display");
require(VaultRules.formatBalance(1_500L).equals("1.5K"), "compact balance display");
require(VaultRules.formatBalance(1_000_000L).equals("1M"), "million balance display");
require(VaultRules.progressPercent(0) == 0, "initial progress");
require(VaultRules.progressPercent(600) == 50, "half progress");
require(VaultRules.progressPercent(1_200) == 100, "complete progress");
+1026 -105
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.12
ambiance_version=0.0.0-alpha.15
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.10
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.52
anotherworld_version=0.0.0-alpha.55
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.31
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.15
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.19
onlyfun_version=0.0.0-alpha.24
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.117
sanctuary_version=0.0.0-alpha.119
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.203
pack_version=26.2.0-alpha.210
maven_group=fr.koka99cab.sanctuary26
+17 -6
View File
@@ -16,6 +16,14 @@ 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.named("test") {
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
failOnNoDiscoveredTests = false
@@ -168,6 +176,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 +193,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"))
@@ -295,7 +305,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.15"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -331,7 +341,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.15"
|| 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 +370,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.15"
|| 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 +402,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.15"
|| 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
@@ -411,6 +421,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
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")
@@ -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()));
}
}
@@ -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);
}
}
@@ -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,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
+218 -8
View File
@@ -3,6 +3,22 @@ import groovy.json.JsonSlurper
import javax.imageio.ImageIO
import java.security.MessageDigest
tasks.register("canapliaFoundationSmoke", JavaExec) {
group = "verification"
description = "Checks the stable Canaplia namespace, variant keys and Ambiance visual boundary."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundationSmoke"
}
tasks.register("canapliaGameplaySmoke", JavaExec) {
group = "verification"
description = "Checks Canaplia discovery, hybrids, effects and bounded Codex payloads."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaGameplaySmoke"
}
tasks.register("culinaryBatchingSmoke", JavaExec) {
group = "verification"
description = "Checks one-stack-per-ingredient cauldron batch quantities."
@@ -24,9 +40,132 @@ tasks.named("test") {
failOnNoDiscoveredTests = false
}
tasks.named("check") {
dependsOn tasks.named("canapliaFoundationSmoke")
dependsOn tasks.named("canapliaGameplaySmoke")
}
def generatedResources = layout.buildDirectory.dir("generated/resources")
def generatedCanapliaResources = layout.buildDirectory.dir("generated/canapliaResources")
sourceSets.main.resources.srcDir(generatedResources)
sourceSets.main.resources.srcDir(generatedCanapliaResources)
def canapliaVariants = [
[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 models, loot and recipes around the restored authored Canaplia sprites."
outputs.dir(generatedCanapliaResources)
doLast {
def root = generatedCanapliaResources.get().asFile
delete(root)
def writeJson = { String relativePath, Object value ->
def target = new File(root, relativePath)
target.parentFile.mkdirs()
target.text = JsonOutput.prettyPrint(JsonOutput.toJson(value)) + "\n"
}
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]])
}
def generatedItem = { String itemPath ->
writeJson("assets/canaplia/models/item/${itemPath}.json", [
parent: "minecraft:item/generated", textures: [layer0: "canaplia:item/${itemPath}"]])
itemDefinition(itemPath, "canaplia:item/${itemPath}")
}
def cropVariants = [:]
def topVariants = [:]
def resinVariants = [:]
canapliaVariants.each { variant ->
String key = variant.key
(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/${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/${topTexture(key)}"]])
}
String seed = path(key, "hemp_seeds")
String bud = path(key, "hemp_bud")
String resin = path(key, "resin_block")
String joint = path(key, "rolled_joint")
[seed, bud, joint].each { generatedItem(it) }
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}"]])
resinVariants["variant=${key}"] = [model: "canaplia:block/resin_block_${key}"]
writeJson("data/canaplia/recipe/${joint}.json", [
type: "minecraft:crafting_shapeless", category: "misc",
ingredients: ["minecraft:paper", "canaplia:${bud}"], result: [id: "canaplia:${joint}"]])
}
writeJson("assets/canaplia/blockstates/hemp_crop.json", [variants: cropVariants])
writeJson("assets/canaplia/blockstates/hemp_top.json", [variants: topVariants])
writeJson("assets/canaplia/blockstates/resin_block.json", [variants: resinVariants])
def cropSeedChildren = canapliaVariants.collect { variant ->
String seed = path(variant.key, "hemp_seeds")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:hemp_crop", properties: [variant: variant.key]]], name: "canaplia:${seed}"]
}
def cropBudChildren = canapliaVariants.collect { variant ->
String bud = path(variant.key, "hemp_bud")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:hemp_crop", properties: [age: "7", variant: variant.key]]],
functions: [[function: "minecraft:set_count", count: [type: "minecraft:uniform", min: 1.0, max: 3.0]]],
name: "canaplia:${bud}"]
}
writeJson("data/canaplia/loot_table/blocks/hemp_crop.json", [type: "minecraft:block",
functions: [[function: "minecraft:explosion_decay"]], pools: [
[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: cropSeedChildren]]],
[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: cropBudChildren]]]
], random_sequence: "canaplia:blocks/hemp_crop"])
writeJson("data/canaplia/loot_table/blocks/hemp_top.json", [type: "minecraft:block", pools: []])
def resinChildren = canapliaVariants.collect { variant ->
String resin = path(variant.key, "resin_block")
[type: "minecraft:item", conditions: [[condition: "minecraft:block_state_property",
block: "canaplia:resin_block", properties: [variant: variant.key]]], name: "canaplia:${resin}"]
}
writeJson("data/canaplia/loot_table/blocks/resin_block.json", [type: "minecraft:block",
functions: [[function: "minecraft:explosion_decay"]],
pools: [[rolls: 1.0, entries: [[type: "minecraft:alternatives", children: resinChildren]]]],
random_sequence: "canaplia:blocks/resin_block"])
def seedIds = canapliaVariants.collect { "canaplia:${path(it.key, 'hemp_seeds')}" }
def budIds = canapliaVariants.collect { "canaplia:${path(it.key, 'hemp_bud')}" }
writeJson("data/c/tags/item/seeds.json", [replace: false, values: seedIds])
writeJson("data/canaplia/tags/item/hemp_buds.json", [replace: false, values: budIds])
itemDefinition("codex", "minecraft:item/enchanted_book")
itemDefinition("codex_page", "minecraft:item/paper")
writeJson("data/canaplia/recipe/codex.json", [
type: "minecraft:crafting_shaped", category: "misc",
pattern: [" P ", "PBP", " P "],
key: [P: "canaplia:codex_page", B: "minecraft:book"],
result: [id: "canaplia:codex"]])
}
}
tasks.named("processResources") {
dependsOn tasks.named("generateCanapliaResources")
}
def agricultureCrops = [
[name: "strawberry", crop: "strawberry_crop", seed: "strawberry_seeds", produce: "strawberry"],
@@ -318,7 +457,7 @@ tasks.register("generateAgricultureResources") {
smokingRecipe("roasted_coffee_beans", "coffee_beans", "roasted_coffee_beans")
smokingRecipe("dried_vanilla_pod", "green_vanilla_pod", "vanilla_pod")
def cropBlocks = agricultureCrops.collect { "itsalive:${it.crop}" }
def cropBlocks = agricultureCrops.collect { "itsalive:${it.crop}" } + ["canaplia:hemp_crop"]
def plantingItems = agricultureCrops.collect { "itsalive:${it.seed}" }.unique()
tag("block", "minecraft", "crops", cropBlocks)
tag("block", "minecraft", "maintains_farmland", cropBlocks.findAll { it != "itsalive:rice_crop" })
@@ -417,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.31"
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")
@@ -936,8 +1075,8 @@ tasks.register("verifyItsAlive") {
}
}
def cropTag = new JsonSlurper().parse(new File(generatedRoot, "data/minecraft/tags/block/crops.json"))
if (cropTag.values.toSet() != agricultureCrops.collect { "itsalive:${it.crop}" }.toSet()) {
throw new GradleException("The minecraft:crops tag must contain the twenty It's Alive crops exactly once")
if (cropTag.values.toSet() != (agricultureCrops.collect { "itsalive:${it.crop}" } + ["canaplia:hemp_crop"]).toSet()) {
throw new GradleException("The minecraft:crops tag must contain the twenty It's Alive crops and Canaplia exactly once")
}
def herbsTag = new JsonSlurper().parse(new File(generatedRoot, "data/itsalive/tags/item/herbs.json"))
if (herbsTag.values.toSet() != ["itsalive:basil", "itsalive:mint", "itsalive:coriander"].toSet()) {
@@ -1174,7 +1313,7 @@ tasks.register("verifyCulinaryReliability") {
}
}
}
if (project.version.toString() != "0.0.0-alpha.31"
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
@@ -1212,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.31"
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"
@@ -1250,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.31"
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
@@ -1298,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.31"
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
@@ -1322,8 +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")
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.itsalive;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundation;
import fr.koka99cab.sanctuary26.itsalive.network.ItsAliveNetworking;
import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryRecipeKnowledge;
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveRegistries;
@@ -12,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 = 1;
public static final int CURRENT_DATA_VERSION = 3;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ItsAliveMod() {
@@ -24,6 +25,7 @@ public final class ItsAliveMod implements ModInitializer {
@Override
public void onInitialize() {
CanapliaFoundation.initialize();
ItsAliveRegistries.initialize();
ItsAliveNetworking.initialize();
CulinaryRecipeKnowledge.initialize();
@@ -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;
}
}
@@ -0,0 +1,92 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Per-dimension composter state that reads the unversioned 2.0 list and writes a monotone wrapper. */
public final class CanapliaComposterData extends SavedData {
public static final int DATA_VERSION = 1;
private static final int MAX_ENTRIES = 16_384;
public record Entry(long pos, int budCount, String sourceVariant) {
private static final Codec<Entry> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("pos").forGetter(Entry::pos),
Codec.INT.fieldOf("bud_count").forGetter(Entry::budCount),
Codec.STRING.optionalFieldOf("source_variant", "common").forGetter(Entry::sourceVariant)
).apply(instance, Entry::new));
public Entry {
budCount = Math.clamp(budCount, 1, CanapliaComposting.REQUIRED_BUDS);
sourceVariant = CanapliaVariantKey.byKey(sourceVariant).map(CanapliaVariantKey::key).orElse("common");
}
}
private static final Codec<CanapliaComposterData> VERSIONED_CODEC = RecordCodecBuilder.create(instance ->
instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Entry.CODEC.listOf().optionalFieldOf("entries", List.of()).forGetter(CanapliaComposterData::entries)
).apply(instance, CanapliaComposterData::new));
private static final Codec<CanapliaComposterData> LEGACY_CODEC = Entry.CODEC.listOf()
.xmap(entries -> new CanapliaComposterData(0, entries), CanapliaComposterData::entries);
private static final Codec<CanapliaComposterData> CODEC = Codec.either(VERSIONED_CODEC, LEGACY_CODEC)
.xmap(value -> value.map(data -> data, data -> data), Either::left);
private static final SavedDataType<CanapliaComposterData> TYPE = new SavedDataType<>(
CanapliaLegacyCatalog.SAVED_DATA_IDS.getFirst(), CanapliaComposterData::new, CODEC, null);
private int dataVersion;
private final Map<Long, Entry> tracked = new HashMap<>();
public CanapliaComposterData() {
this(DATA_VERSION, List.of());
}
private CanapliaComposterData(int dataVersion, List<Entry> entries) {
this.dataVersion = Math.max(0, dataVersion);
entries.stream().limit(MAX_ENTRIES).forEach(entry -> tracked.put(entry.pos(), entry));
}
public static CanapliaComposterData get(ServerLevel level) {
CanapliaComposterData data = level.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
}
return data;
}
public Entry entry(BlockPos pos) {
return tracked.get(pos.asLong());
}
public Entry record(BlockPos pos, CanapliaVariantKey variant) {
Entry previous = tracked.get(pos.asLong());
if (previous != null && !previous.sourceVariant().equals(variant.key())) {
throw new IllegalArgumentException("Tracked Canaplia composter already contains another variant");
}
int count = Math.min(CanapliaComposting.REQUIRED_BUDS, previous == null ? 1 : previous.budCount() + 1);
Entry current = new Entry(pos.asLong(), count, variant.key());
tracked.put(pos.asLong(), current);
setDirty();
return current;
}
public void clear(BlockPos pos) {
if (tracked.remove(pos.asLong()) != null) setDirty();
}
private List<Entry> entries() {
return tracked.values().stream().limit(MAX_ENTRIES).toList();
}
static Codec<CanapliaComposterData> codecForTests() {
return CODEC;
}
}
@@ -0,0 +1,93 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ComposterBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.gameevent.GameEvent;
/** Deterministically turns ten matching buds into the corresponding resin block. */
public final class CanapliaComposting {
public static final int REQUIRED_BUDS = 10;
private static final int FILL_EVENT = 1500;
private static boolean initialized;
private CanapliaComposting() {}
public static void initialize() {
if (initialized) return;
initialized = true;
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
if (hand != InteractionHand.MAIN_HAND) return InteractionResult.PASS;
BlockState state = level.getBlockState(hit.getBlockPos());
if (!state.is(Blocks.COMPOSTER)) return InteractionResult.PASS;
ItemStack stack = player.getItemInHand(hand);
CanapliaVariantKey variant = budVariant(stack);
if (level.isClientSide()) {
if (variant != null && state.getValue(ComposterBlock.LEVEL) < ComposterBlock.READY) {
return InteractionResult.SUCCESS;
}
return stack.isEmpty() && state.getValue(ComposterBlock.LEVEL) == ComposterBlock.READY
? InteractionResult.SUCCESS : InteractionResult.PASS;
}
if (!(level instanceof ServerLevel serverLevel)) return InteractionResult.FAIL;
CanapliaComposterData data = CanapliaComposterData.get(serverLevel);
CanapliaComposterData.Entry entry = data.entry(hit.getBlockPos());
if (entry != null && expectedLevel(entry.budCount()) != state.getValue(ComposterBlock.LEVEL)) {
data.clear(hit.getBlockPos());
entry = null;
}
if (stack.isEmpty() && entry != null && entry.budCount() == REQUIRED_BUDS) {
CanapliaVariantKey source = CanapliaVariantKey.byKey(entry.sourceVariant())
.orElse(CanapliaVariantKey.COMMON);
ItemStack resin = new ItemStack(CanapliaContent.family(source).resin());
data.clear(hit.getBlockPos());
serverLevel.setBlock(hit.getBlockPos(), state.setValue(ComposterBlock.LEVEL, 0), Block.UPDATE_ALL);
if (!player.addItem(resin)) Block.popResource(serverLevel, hit.getBlockPos().above(), resin);
serverLevel.playSound(null, hit.getBlockPos(), SoundEvents.COMPOSTER_EMPTY,
SoundSource.BLOCKS, 1.0F, 1.0F);
serverLevel.gameEvent(player, GameEvent.BLOCK_CHANGE, hit.getBlockPos());
return InteractionResult.SUCCESS_SERVER;
}
if (variant == null) return InteractionResult.PASS;
if (entry == null && state.getValue(ComposterBlock.LEVEL) != 0) return InteractionResult.PASS;
if (entry != null && !entry.sourceVariant().equals(variant.key())) {
player.sendSystemMessage(Component.translatable("message.canaplia.composter.mixed"));
return InteractionResult.FAIL;
}
if (entry != null && entry.budCount() >= REQUIRED_BUDS) return InteractionResult.FAIL;
CanapliaComposterData.Entry updated = data.record(hit.getBlockPos(), variant);
int newLevel = expectedLevel(updated.budCount());
if (!player.hasInfiniteMaterials()) stack.shrink(1);
serverLevel.setBlock(hit.getBlockPos(), state.setValue(ComposterBlock.LEVEL, newLevel), Block.UPDATE_ALL);
serverLevel.levelEvent(FILL_EVENT, hit.getBlockPos(), 1);
serverLevel.gameEvent(player, GameEvent.BLOCK_CHANGE, hit.getBlockPos());
if (updated.budCount() == REQUIRED_BUDS) {
serverLevel.playSound(null, hit.getBlockPos(), SoundEvents.COMPOSTER_READY,
SoundSource.BLOCKS, 1.0F, 1.0F);
}
return InteractionResult.SUCCESS_SERVER;
});
}
public static int expectedLevel(int budCount) {
int bounded = Math.clamp(budCount, 1, REQUIRED_BUDS);
return bounded == REQUIRED_BUDS ? ComposterBlock.READY
: (bounded * ComposterBlock.MAX_LEVEL + REQUIRED_BUDS - 1) / REQUIRED_BUDS;
}
private static CanapliaVariantKey budVariant(ItemStack stack) {
CanapliaVariantKey variant = CanapliaContent.variantOf(stack.getItem());
return variant != null && stack.getItem() == CanapliaContent.family(variant).bud() ? variant : null;
}
}
@@ -0,0 +1,132 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
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;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.ComposterBlock;
/** Registers the historical namespace once while sharing three runtime blocks across all variants. */
public final class CanapliaContent {
public record Family(Item seeds, Item bud, Item resin, Item joint) {}
public static final CanapliaHempCropBlock HEMP_CROP = registerBlock("hemp_crop",
key -> new CanapliaHempCropBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.WHEAT).setId(key)));
public static final CanapliaHempTopBlock HEMP_TOP = registerBlock("hemp_top",
key -> new CanapliaHempTopBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.WHEAT).noLootTable().setId(key)));
public static final CanapliaResinBlock RESIN_BLOCK = registerBlock("resin_block",
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();
private static boolean registered;
private CanapliaContent() {}
public static void register() {
if (registered) return;
registered = true;
for (Family family : FAMILIES.values()) {
ComposterBlock.COMPOSTABLES.put(family.seeds(), 0.30F);
}
ItsAliveMod.LOGGER.info("[Canaplia] Registered {} variants, {} historical items and {} shared blocks.",
FAMILIES.size(), FAMILIES.size() * 4, 3);
}
public static Family family(CanapliaVariantKey variant) {
Family family = FAMILIES.get(variant);
if (family == null) throw new IllegalArgumentException("Unknown Canaplia variant " + variant);
return family;
}
public static List<Item> creativeItems() {
return CREATIVE_ITEMS;
}
public static CanapliaVariantKey variantOf(Item item) {
for (Map.Entry<CanapliaVariantKey, Family> entry : FAMILIES.entrySet()) {
Family family = entry.getValue();
if (item == family.seeds() || item == family.bud() || item == family.resin() || item == family.joint()) {
return entry.getKey();
}
}
return null;
}
public static Item resinForBud(Item bud) {
CanapliaVariantKey variant = variantOf(bud);
return variant == null ? null : family(variant).resin();
}
private static Map<CanapliaVariantKey, Family> registerFamilies() {
EnumMap<CanapliaVariantKey, Family> families = new EnumMap<>(CanapliaVariantKey.class);
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
Item seeds = registerVariantBlockItem(variant.legacyItemPath("hemp_seeds"), HEMP_CROP, variant);
Item bud = registerItem(variant.legacyItemPath("hemp_bud"), key -> new Item(new Item.Properties().setId(key)));
Item resin = registerVariantBlockItem(variant.legacyItemPath("resin_block"), RESIN_BLOCK, variant);
Item joint = registerItem(variant.legacyItemPath("rolled_joint"), key ->
new CanapliaJointItem(variant, new Item.Properties().stacksTo(16).setId(key)));
families.put(variant, new Family(seeds, bud, resin, joint));
}
return Map.copyOf(families);
}
private static List<Item> createCreativeItems() {
List<Item> items = new ArrayList<>(66);
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
Family family = family(variant);
items.add(family.seeds());
items.add(family.bud());
items.add(family.resin());
items.add(family.joint());
}
items.add(CODEX_PAGE);
items.add(CODEX);
return List.copyOf(items);
}
private static Item registerVariantBlockItem(String path, Block block, CanapliaVariantKey variant) {
return registerItem(path, key -> new CanapliaVariantBlockItem(block, variant, new Item.Properties().setId(key)));
}
private static <T extends Block> T registerBlock(String path,
java.util.function.Function<ResourceKey<Block>, T> factory) {
ResourceKey<Block> key = ResourceKey.create(Registries.BLOCK, CanapliaLegacyCatalog.id(path));
return Registry.register(BuiltInRegistries.BLOCK, key, factory.apply(key));
}
private static Item registerItem(String path, java.util.function.Function<ResourceKey<Item>, Item> factory) {
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, CanapliaLegacyCatalog.id(path));
return Registry.register(BuiltInRegistries.ITEM, key, factory.apply(key));
}
private static BlockEntityType<CanapliaResinBlockEntity> registerResinBlockEntity() {
ResourceKey<BlockEntityType<?>> key = ResourceKey.create(Registries.BLOCK_ENTITY_TYPE,
CanapliaLegacyCatalog.id("resin_block"));
return Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE, key,
new BlockEntityType<>(CanapliaResinBlockEntity::new, Set.of(RESIN_BLOCK)));
}
}
@@ -0,0 +1,62 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaForagingRules.Dimension;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaForagingRules.Source;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BiomeTags;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
/** Break-based seed discovery works in existing chunks and emits at most one bounded drop. */
public final class CanapliaDiscovery {
private static boolean initialized;
private CanapliaDiscovery() {}
public static void initialize() {
if (initialized) return;
initialized = true;
PlayerBlockBreakEvents.AFTER.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel) || !(player instanceof ServerPlayer serverPlayer)
|| player.hasInfiniteMaterials()) return;
CanapliaDiscoverySources.Match source = CanapliaDiscoverySources.resolve(state).orElse(null);
if (source == null) return;
Holder<Biome> biome = level.getBiome(pos);
ClimateCropForaging.CropClimate climate = ClimateCropForaging.climate(biome);
Identifier biomeId = biome.unwrapKey().map(key -> key.identifier()).orElse(null);
String path = biomeId == null ? "" : biomeId.getPath();
CanapliaForagingRules.Context context = new CanapliaForagingRules.Context(
dimension(level), source.source(), climate.temperature(), climate.humidity(),
biome.is(BiomeTags.IS_FOREST) || biome.is(BiomeTags.IS_RIVER),
biome.is(BiomeTags.IS_BADLANDS),
state.is(Blocks.DARK_OAK_LEAVES) || path.contains("dark_forest"),
path.contains("swamp") || path.contains("lush_caves"),
level.getMaxLocalRawBrightness(pos), source.numerator(), source.denominator()
);
CanapliaForagingRules.candidate(context).ifPresent(candidate -> {
if (!candidate.succeeds(serverLevel.getRandom().nextInt(candidate.denominator()))) return;
Block.popResource(serverLevel, pos,
new ItemStack(CanapliaContent.family(candidate.variant()).seeds()));
CanapliaKnowledge.discover(serverPlayer, candidate.variant(), "foraging");
});
});
}
private static Dimension dimension(Level level) {
if (level.dimension().equals(Level.OVERWORLD)) return Dimension.OVERWORLD;
if (level.dimension().equals(Level.NETHER)) return Dimension.NETHER;
if (level.dimension().equals(Level.END)) return Dimension.END;
return Dimension.OTHER;
}
}
@@ -0,0 +1,128 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaForagingRules.Source;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import net.fabricmc.fabric.api.resource.v1.ResourceLoader;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.FileToIdConverter;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.tags.TagKey;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Data-pack-owned mapping from eligible flora to bounded server discovery sources. */
public final class CanapliaDiscoverySources
extends SimplePreparableReloadListener<List<CanapliaDiscoverySources.Rule>> {
public static final int SCHEMA_VERSION = 1;
private static final int MAX_RULES = 128;
private static final FileToIdConverter CONVERTER = FileToIdConverter.json("canaplia_discovery");
private static volatile List<Rule> rules = List.of();
public record Rule(Set<Block> blocks, Set<TagKey<Block>> tags, Source source, int numerator, int denominator) {
public Rule {
blocks = Set.copyOf(blocks);
tags = Set.copyOf(tags);
if (blocks.isEmpty() && tags.isEmpty()) throw new IllegalArgumentException("A discovery rule needs blocks or tags");
if (source == null || numerator < 1 || numerator >= denominator || denominator > 4096) {
throw new IllegalArgumentException("Invalid Canaplia discovery rule");
}
}
boolean matches(BlockState state) {
return blocks.contains(state.getBlock()) || tags.stream().anyMatch(state::is);
}
}
public record Match(Source source, int numerator, int denominator) {}
public static void register() {
ResourceLoader.get(PackType.SERVER_DATA).registerReloadListener(
ItsAliveMod.id("canaplia_discovery_sources"), new CanapliaDiscoverySources());
}
public static Optional<Match> resolve(BlockState state) {
return rules.stream().filter(rule -> rule.matches(state)).findFirst()
.map(rule -> new Match(rule.source(), rule.numerator(), rule.denominator()));
}
@Override
protected List<Rule> prepare(ResourceManager resources, ProfilerFiller profiler) {
List<Rule> loaded = new ArrayList<>();
for (Map.Entry<Identifier, Resource> entry : CONVERTER.listMatchingResources(resources).entrySet()) {
try (var reader = entry.getValue().openAsReader()) {
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
int schema = root.has("schema_version") ? root.get("schema_version").getAsInt() : SCHEMA_VERSION;
if (schema != SCHEMA_VERSION) throw new IllegalArgumentException("unsupported schema_version " + schema);
JsonArray entries = root.getAsJsonArray("sources");
if (entries == null || entries.isEmpty()) throw new IllegalArgumentException("sources must be non-empty");
for (JsonElement element : entries) {
if (loaded.size() >= MAX_RULES) throw new IllegalArgumentException("too many discovery rules");
loaded.add(parse(element.getAsJsonObject()));
}
} catch (IOException | RuntimeException exception) {
throw new IllegalStateException("Invalid Canaplia discovery source " + entry.getKey()
+ " from " + entry.getValue().sourcePackId(), exception);
}
}
if (loaded.isEmpty()) throw new IllegalStateException("No Canaplia discovery sources were loaded");
return List.copyOf(loaded);
}
@Override
protected void apply(List<Rule> loaded, ResourceManager resources, ProfilerFiller profiler) {
rules = loaded;
ItsAliveMod.LOGGER.info("[Canaplia] Loaded {} bounded discovery source rules from data packs.", loaded.size());
}
private static Rule parse(JsonObject json) {
Source source = Source.valueOf(requiredString(json, "source").toUpperCase(java.util.Locale.ROOT));
int numerator = requiredInt(json, "chance_numerator");
int denominator = requiredInt(json, "chance_denominator");
Set<Block> blocks = new LinkedHashSet<>();
if (json.has("blocks")) {
for (JsonElement value : json.getAsJsonArray("blocks")) {
Identifier id = Identifier.parse(value.getAsString());
blocks.add(BuiltInRegistries.BLOCK.getOptional(id)
.orElseThrow(() -> new IllegalArgumentException("unknown discovery block " + id)));
}
}
Set<TagKey<Block>> tags = new LinkedHashSet<>();
if (json.has("block_tags")) {
for (JsonElement value : json.getAsJsonArray("block_tags")) {
tags.add(TagKey.create(Registries.BLOCK, Identifier.parse(value.getAsString())));
}
}
return new Rule(blocks, tags, source, numerator, denominator);
}
private static String requiredString(JsonObject json, String key) {
if (!json.has(key) || !json.get(key).isJsonPrimitive()) {
throw new IllegalArgumentException("missing string " + key);
}
return json.get(key).getAsString();
}
private static int requiredInt(JsonObject json, String key) {
if (!json.has(key) || !json.get(key).isJsonPrimitive()) {
throw new IllegalArgumentException("missing integer " + key);
}
return json.get(key).getAsInt();
}
}
@@ -0,0 +1,48 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
/** Bounded timing and stacking policy for server-owned Canaplia sessions. */
public final class CanapliaEffectRules {
public static final int MIN_PRIMARY_TICKS = 140 * 20;
public static final int MAX_PRIMARY_TICKS = 180 * 20;
public static final int MIN_BAD_TRIP_TICKS = 25 * 20;
public static final int MAX_BAD_TRIP_TICKS = 40 * 20;
public static final int MIN_BAD_TRIP_DELAY_TICKS = 12 * 20;
public static final int MAX_BAD_TRIP_DELAY_TICKS = 70 * 20;
public static final int BAD_TRIP_CHANCE = 6;
public static final int AURA_REFRESH_TICKS = 4 * 20;
public record Plan(int primaryTicks, boolean badTrip, int badTripDelayTicks, int badTripTicks) {
public Plan {
primaryTicks = Math.clamp(primaryTicks, MIN_PRIMARY_TICKS, MAX_PRIMARY_TICKS);
badTripDelayTicks = badTrip ? Math.clamp(badTripDelayTicks,
MIN_BAD_TRIP_DELAY_TICKS, Math.min(MAX_BAD_TRIP_DELAY_TICKS, primaryTicks - MIN_BAD_TRIP_TICKS)) : 0;
badTripTicks = badTrip ? Math.clamp(badTripTicks, MIN_BAD_TRIP_TICKS, MAX_BAD_TRIP_TICKS) : 0;
}
}
private CanapliaEffectRules() {}
public static Plan plan(int primaryRoll, int badTripRoll, int delayRoll, int durationRoll) {
int primary = inclusive(MIN_PRIMARY_TICKS, MAX_PRIMARY_TICKS, primaryRoll);
boolean badTrip = Math.floorMod(badTripRoll, BAD_TRIP_CHANCE) == 0;
int delay = inclusive(MIN_BAD_TRIP_DELAY_TICKS,
Math.min(MAX_BAD_TRIP_DELAY_TICKS, primary - MIN_BAD_TRIP_TICKS), delayRoll);
int duration = inclusive(MIN_BAD_TRIP_TICKS, MAX_BAD_TRIP_TICKS, durationRoll);
return new Plan(primary, badTrip, delay, duration);
}
public static int refreshedExpiry(int now, int currentExpiry, int refreshedDuration) {
int maximum = now + MAX_PRIMARY_TICKS;
int boundedCurrent = Math.clamp(currentExpiry, now, maximum);
int boundedRefresh = now + Math.clamp(refreshedDuration, MIN_PRIMARY_TICKS, MAX_PRIMARY_TICKS);
return Math.max(boundedCurrent, boundedRefresh);
}
public static boolean expired(int now, int expiresAt) {
return now >= expiresAt;
}
private static int inclusive(int minimum, int maximum, int roll) {
return minimum + Math.floorMod(roll, maximum - minimum + 1);
}
}
@@ -0,0 +1,173 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.core.Holder;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
/** Authoritative, in-memory and bounded temporary sessions. Reconnection always starts clean. */
public final class CanapliaEffects {
private enum VisualPhase { NONE, NORMAL, BAD_TRIP }
private static final int EFFECT_REFRESH_INTERVAL = 20;
private static final int EFFECT_REFRESH_TICKS = 45;
private static final int RESYNC_INTERVAL = 20 * 15;
private static final Map<UUID, Session> SESSIONS = new HashMap<>();
private static boolean initialized;
private CanapliaEffects() {}
public static void initialize() {
if (initialized) return;
initialized = true;
ServerTickEvents.END_SERVER_TICK.register(CanapliaEffects::tick);
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) ->
SESSIONS.remove(handler.player.getUUID()));
ServerLivingEntityEvents.AFTER_DEATH.register((entity, source) -> {
if (entity instanceof ServerPlayer player && SESSIONS.remove(player.getUUID()) != null) clear(player);
});
}
public static void consume(ServerPlayer player, CanapliaVariantKey variant) {
int now = player.level().getServer().getTickCount();
Session current = SESSIONS.get(player.getUUID());
if (current != null && current.variant == variant && !current.auraOnly) {
int duration = CanapliaEffectRules.plan(player.getRandom().nextInt(), 1,
player.getRandom().nextInt(), player.getRandom().nextInt()).primaryTicks();
current.expiresAt = CanapliaEffectRules.refreshedExpiry(now, current.expiresAt, duration);
current.lastSyncAt = Integer.MIN_VALUE;
syncNormal(player, current, now);
return;
}
CanapliaEffectRules.Plan plan = CanapliaEffectRules.plan(
player.getRandom().nextInt(), player.getRandom().nextInt(),
player.getRandom().nextInt(), player.getRandom().nextInt());
Session session = new Session(variant, now + plan.primaryTicks(),
plan.badTrip() ? now + plan.badTripDelayTicks() : Integer.MAX_VALUE,
plan.badTrip() ? now + plan.badTripDelayTicks() + plan.badTripTicks() : Integer.MAX_VALUE,
false);
SESSIONS.put(player.getUUID(), session);
syncNormal(player, session, now);
}
/** A nearby resin block refreshes one weak aura and never performs a bad-trip roll. */
public static void refreshAura(ServerPlayer player, CanapliaVariantKey variant) {
int now = player.level().getServer().getTickCount();
Session current = SESSIONS.get(player.getUUID());
if (current != null && !current.auraOnly) return;
if (current == null || current.variant != variant) {
current = new Session(variant, now + CanapliaEffectRules.AURA_REFRESH_TICKS,
Integer.MAX_VALUE, Integer.MAX_VALUE, true);
SESSIONS.put(player.getUUID(), current);
syncNormal(player, current, now);
} else {
current.expiresAt = now + CanapliaEffectRules.AURA_REFRESH_TICKS;
}
}
private static void tick(MinecraftServer server) {
int now = server.getTickCount();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
Session session = SESSIONS.get(player.getUUID());
if (session == null) continue;
if (player.isDeadOrDying() || CanapliaEffectRules.expired(now, session.expiresAt)) {
SESSIONS.remove(player.getUUID());
clear(player);
continue;
}
boolean badTrip = now >= session.badStartsAt && now < session.badEndsAt;
VisualPhase phase = badTrip ? VisualPhase.BAD_TRIP : VisualPhase.NORMAL;
if (now % EFFECT_REFRESH_INTERVAL == 0) {
applyPrimary(player, session.variant, session.auraOnly);
if (badTrip) {
apply(player, MobEffects.NAUSEA, 0);
apply(player, MobEffects.HUNGER, session.variant == CanapliaVariantKey.DARK ? 1 : 0);
}
}
if (phase != session.phase || now - session.lastSyncAt >= RESYNC_INTERVAL) {
if (badTrip) syncBadTrip(player, session, now);
else syncNormal(player, session, now);
}
}
}
private static void applyPrimary(ServerPlayer player, CanapliaVariantKey variant, boolean auraOnly) {
int amplifier = 0;
switch (variant) {
case COMMON -> apply(player, MobEffects.SPEED, amplifier);
case TENDER, MOSS -> apply(player, MobEffects.REGENERATION, amplifier);
case OLIVE -> apply(player, MobEffects.RESISTANCE, amplifier);
case DARK, PURPLE -> apply(player, MobEffects.NIGHT_VISION, amplifier);
case ASHEN -> apply(player, MobEffects.SLOW_FALLING, amplifier);
case GOLD -> apply(player, MobEffects.LUCK, amplifier);
case AMBER -> apply(player, MobEffects.HASTE, amplifier);
case BLUISH -> apply(player, MobEffects.WATER_BREATHING, amplifier);
case PINK -> apply(player, MobEffects.JUMP_BOOST, amplifier);
case CORAL -> apply(player, MobEffects.DOLPHINS_GRACE, amplifier);
case INDIGO -> { apply(player, MobEffects.NIGHT_VISION, amplifier); apply(player, MobEffects.SLOW_FALLING, amplifier); }
case VARIEG -> { apply(player, MobEffects.LUCK, amplifier); if (!auraOnly) apply(player, MobEffects.SPEED, amplifier); }
case LUNAR -> { apply(player, MobEffects.NIGHT_VISION, amplifier); apply(player, MobEffects.SLOW_FALLING, amplifier); }
case EMBER -> apply(player, MobEffects.FIRE_RESISTANCE, amplifier);
}
}
private static void apply(ServerPlayer player, Holder<MobEffect> effect, int amplifier) {
player.addEffect(new MobEffectInstance(effect, EFFECT_REFRESH_TICKS,
Math.clamp(amplifier, 0, 1), true, false, true));
}
private static void syncNormal(ServerPlayer player, Session session, int now) {
if (ServerPlayNetworking.canSend(player, AmbianceCanapliaVisualPayloads.Normal.ID)) {
ServerPlayNetworking.send(player, new AmbianceCanapliaVisualPayloads.Normal(
AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION,
CanapliaVariants.definition(session.variant).normalVisual(), session.expiresAt - now));
}
session.phase = VisualPhase.NORMAL;
session.lastSyncAt = now;
}
private static void syncBadTrip(ServerPlayer player, Session session, int now) {
if (ServerPlayNetworking.canSend(player, AmbianceCanapliaVisualPayloads.BadTrip.ID)) {
ServerPlayNetworking.send(player, new AmbianceCanapliaVisualPayloads.BadTrip(
AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION,
CanapliaVariants.definition(session.variant).badTripVisual(), session.badEndsAt - now));
}
session.phase = VisualPhase.BAD_TRIP;
session.lastSyncAt = now;
}
private static void clear(ServerPlayer player) {
if (ServerPlayNetworking.canSend(player, AmbianceCanapliaVisualPayloads.Clear.ID)) {
ServerPlayNetworking.send(player,
new AmbianceCanapliaVisualPayloads.Clear(AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION));
}
}
private static final class Session {
private final CanapliaVariantKey variant;
private int expiresAt;
private final int badStartsAt;
private final int badEndsAt;
private final boolean auraOnly;
private VisualPhase phase = VisualPhase.NONE;
private int lastSyncAt = Integer.MIN_VALUE;
private Session(CanapliaVariantKey variant, int expiresAt, int badStartsAt, int badEndsAt,
boolean auraOnly) {
this.variant = variant;
this.expiresAt = expiresAt;
this.badStartsAt = badStartsAt;
this.badEndsAt = badEndsAt;
this.auraOnly = auraOnly;
}
}
}
@@ -0,0 +1,84 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging.Humidity;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging.Temperature;
import java.util.Optional;
/** Central, testable catalogue for old-chunk-compatible wild seed discovery. */
public final class CanapliaForagingRules {
public enum Dimension { OVERWORLD, NETHER, END, OTHER }
public enum Source { LOW_VEGETATION, FLOWER, LEAVES, DEAD_BUSH, MOSS, NETHER_FLORA, CHORUS, OTHER }
public record Context(Dimension dimension, Source source, Temperature temperature, Humidity humidity,
boolean forestOrRiver, boolean badlands, boolean darkCanopy, boolean swampOrLush, int light,
int numerator, int denominator) {
public Context {
if (dimension == null || source == null || temperature == null || humidity == null) {
throw new IllegalArgumentException("Incomplete Canaplia foraging context");
}
if (numerator < 1 || numerator >= denominator || denominator > 4096) {
throw new IllegalArgumentException("Invalid Canaplia foraging probability");
}
light = Math.clamp(light, 0, 15);
}
}
public record Candidate(CanapliaVariantKey variant, int numerator, int denominator) {
public Candidate {
if (variant == null || numerator < 1 || numerator >= denominator || denominator > 4096) {
throw new IllegalArgumentException("Invalid Canaplia foraging candidate");
}
}
public boolean succeeds(int roll) {
return Math.floorMod(roll, denominator) < numerator;
}
}
private CanapliaForagingRules() {}
public static Optional<Candidate> candidate(Context context) {
if (context.dimension() == Dimension.NETHER) {
return context.source() == Source.NETHER_FLORA
? candidate(context, CanapliaVariantKey.EMBER) : Optional.empty();
}
if (context.dimension() == Dimension.END) {
return context.source() == Source.CHORUS
? candidate(context, CanapliaVariantKey.INDIGO) : Optional.empty();
}
if (context.dimension() != Dimension.OVERWORLD) return Optional.empty();
if (context.badlands() && context.source() == Source.DEAD_BUSH) {
return candidate(context, CanapliaVariantKey.AMBER);
}
if ((context.source() == Source.MOSS || context.swampOrLush())
&& context.humidity() == Humidity.HUMID) {
return candidate(context, CanapliaVariantKey.MOSS);
}
if (context.darkCanopy() && context.source() == Source.LEAVES && context.light() <= 7) {
return candidate(context, CanapliaVariantKey.DARK);
}
if (context.source() == Source.FLOWER) {
return candidate(context, CanapliaVariantKey.TENDER);
}
if (context.temperature() == Temperature.HOT && context.humidity() == Humidity.DRY
&& vegetation(context.source())) {
return candidate(context, CanapliaVariantKey.OLIVE);
}
if (context.temperature() == Temperature.COLD && vegetation(context.source())) {
return candidate(context, CanapliaVariantKey.ASHEN);
}
if (context.forestOrRiver() && vegetation(context.source())) {
return candidate(context, CanapliaVariantKey.COMMON);
}
return context.source() == Source.LOW_VEGETATION
? candidate(context, CanapliaVariantKey.COMMON) : Optional.empty();
}
private static Optional<Candidate> candidate(Context context, CanapliaVariantKey variant) {
return Optional.of(new Candidate(variant, context.numerator(), context.denominator()));
}
private static boolean vegetation(Source source) {
return source == Source.LOW_VEGETATION || source == Source.LEAVES || source == Source.DEAD_BUSH;
}
}
@@ -0,0 +1,40 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualEffectApi;
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 = 3;
private static boolean initialized;
private CanapliaFoundation() {
}
public static void initialize() {
if (initialized) {
return;
}
CanapliaLegacyCatalog.validate();
CanapliaVariants.validate();
if (AmbianceVisualEffectApi.CONTRACT_VERSION != 1) {
throw new IllegalStateException("Unsupported Ambiance visual contract");
}
CanapliaContent.register();
CanapliaLectern.initialize();
CanapliaCodexDrops.initialize();
CanapliaDiscoverySources.register();
CanapliaDiscovery.initialize();
CanapliaHarvestService.initialize();
CanapliaComposting.initialize();
CanapliaEffects.initialize();
initialized = true;
ItsAliveMod.LOGGER.info(
"[Canaplia] Runtime v{} registered {} variants and {} historical item IDs; visual contract v{} is ready.",
FOUNDATION_VERSION,
CanapliaLegacyCatalog.variantKeys().size(),
CanapliaLegacyCatalog.ITEM_IDS.size(),
AmbianceVisualEffectApi.CONTRACT_VERSION
);
}
}
@@ -0,0 +1,114 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging.Humidity;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging.Temperature;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/** Pure, ordered rules used by the server at the exact moment a mature plant is harvested. */
public final class CanapliaHarvestRules {
public static final int NEIGHBOR_RADIUS = 1;
public static final int PERCENT_DENOMINATOR = 100;
public record Context(
CanapliaVariantKey parent,
Set<CanapliaVariantKey> matureNeighbors,
Temperature temperature,
Humidity humidity,
int light,
boolean touchingWater,
boolean twilight,
boolean night,
boolean skyVisible,
boolean skyDimension
) {
public Context {
if (parent == null || temperature == null || humidity == null) {
throw new IllegalArgumentException("Canaplia harvest context is incomplete");
}
matureNeighbors = matureNeighbors == null || matureNeighbors.isEmpty()
? Set.of() : Set.copyOf(matureNeighbors);
light = Math.clamp(light, 0, 15);
}
}
public record Candidate(CanapliaVariantKey result, int numerator, int denominator) {
public Candidate {
if (result == null || numerator < 1 || numerator >= denominator || denominator > 4096) {
throw new IllegalArgumentException("Invalid Canaplia harvest candidate");
}
}
public boolean succeeds(int roll) {
return Math.floorMod(roll, denominator) < numerator;
}
}
private CanapliaHarvestRules() {}
/** Returns at most one candidate, so a harvest performs one random draw at most. */
public static Optional<Candidate> candidate(Context context) {
if (isLunar(context)) return Optional.of(percent(CanapliaVariantKey.LUNAR, 6));
if (pair(context, CanapliaVariantKey.TENDER, CanapliaVariantKey.OLIVE)) {
return Optional.of(percent(CanapliaVariantKey.GOLD, 12));
}
if (pair(context, CanapliaVariantKey.TENDER, CanapliaVariantKey.PURPLE) && context.twilight()) {
return Optional.of(percent(CanapliaVariantKey.PINK, 10));
}
if (pair(context, CanapliaVariantKey.AMBER, CanapliaVariantKey.MOSS) && context.touchingWater()) {
return Optional.of(percent(CanapliaVariantKey.CORAL, 10));
}
if (pair(context, CanapliaVariantKey.DARK, CanapliaVariantKey.MOSS)
&& context.light() <= 7 && context.humidity() == Humidity.HUMID) {
return Optional.of(percent(CanapliaVariantKey.PURPLE, 8));
}
if (pair(context, CanapliaVariantKey.ASHEN, CanapliaVariantKey.TENDER)
&& context.temperature() == Temperature.COLD) {
return Optional.of(percent(CanapliaVariantKey.BLUISH, 9));
}
if (context.humidity() == Humidity.HUMID && context.matureNeighbors().stream()
.anyMatch(neighbor -> neighbor != context.parent())) {
return Optional.of(percent(CanapliaVariantKey.VARIEG, 6));
}
return Optional.empty();
}
public static Set<CanapliaVariantKey> parentsFor(CanapliaVariantKey result) {
return switch (result) {
case GOLD -> pairSet(CanapliaVariantKey.TENDER, CanapliaVariantKey.OLIVE);
case PINK -> pairSet(CanapliaVariantKey.TENDER, CanapliaVariantKey.PURPLE);
case CORAL -> pairSet(CanapliaVariantKey.AMBER, CanapliaVariantKey.MOSS);
case PURPLE -> pairSet(CanapliaVariantKey.DARK, CanapliaVariantKey.MOSS);
case BLUISH -> pairSet(CanapliaVariantKey.ASHEN, CanapliaVariantKey.TENDER);
case LUNAR -> pairSet(CanapliaVariantKey.ASHEN, CanapliaVariantKey.BLUISH);
default -> Set.of();
};
}
public static List<CanapliaVariantKey> hybridResults() {
return List.of(CanapliaVariantKey.GOLD, CanapliaVariantKey.PINK, CanapliaVariantKey.CORAL,
CanapliaVariantKey.PURPLE, CanapliaVariantKey.BLUISH, CanapliaVariantKey.VARIEG,
CanapliaVariantKey.LUNAR);
}
private static boolean isLunar(Context context) {
return (context.parent() == CanapliaVariantKey.ASHEN || context.parent() == CanapliaVariantKey.BLUISH)
&& context.temperature() == Temperature.COLD && context.night()
&& context.skyVisible() && context.skyDimension();
}
private static Candidate percent(CanapliaVariantKey result, int percent) {
return new Candidate(result, percent, PERCENT_DENOMINATOR);
}
private static boolean pair(Context context, CanapliaVariantKey first, CanapliaVariantKey second) {
return (context.parent() == first && context.matureNeighbors().contains(second))
|| (context.parent() == second && context.matureNeighbors().contains(first));
}
private static Set<CanapliaVariantKey> pairSet(CanapliaVariantKey first, CanapliaVariantKey second) {
return Set.copyOf(EnumSet.of(first, second));
}
}
@@ -0,0 +1,80 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.ambiance.api.environment.AmbianceLunarHarvestApi;
import fr.koka99cab.sanctuary26.itsalive.agriculture.ClimateCropForaging;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
import java.util.EnumSet;
import java.util.Set;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
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.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Revalidates every hybrid and lunar condition server-side at harvest time. */
public final class CanapliaHarvestService {
private static boolean initialized;
private CanapliaHarvestService() {}
public static void initialize() {
if (initialized) return;
initialized = true;
PlayerBlockBreakEvents.AFTER.register((level, player, pos, state, blockEntity) -> {
if (level instanceof ServerLevel serverLevel && player instanceof ServerPlayer serverPlayer) {
onMatureHarvest(serverLevel, serverPlayer, pos, state);
}
});
}
public static void onMatureHarvest(ServerLevel level, ServerPlayer player, BlockPos pos, BlockState state) {
if (!state.is(CanapliaContent.HEMP_CROP) || !CanapliaContent.HEMP_CROP.isMaxAge(state)) return;
CanapliaVariantKey parent = state.getValue(CanapliaHempCropBlock.VARIANT);
CanapliaKnowledge.discover(player, parent, "harvest");
ClimateCropForaging.CropClimate climate = ClimateCropForaging.climate(level.getBiome(pos));
AmbianceLunarHarvestApi.Conditions lunar = AmbianceLunarHarvestApi.evaluate(level, pos);
long clock = Math.floorMod(level.getDefaultClockTime(), 24_000L);
CanapliaHarvestRules.Context context = new CanapliaHarvestRules.Context(
parent, matureNeighbors(level, pos), climate.temperature(), climate.humidity(),
level.getMaxLocalRawBrightness(pos), touchingWater(level, pos),
(clock >= 11_500L && clock <= 13_000L) || (clock >= 22_000L && clock <= 23_500L),
lunar.night(), lunar.skyVisible(), lunar.skyDimension()
);
CanapliaHarvestRules.candidate(context).ifPresent(candidate -> {
if (!candidate.succeeds(level.getRandom().nextInt(candidate.denominator()))) return;
Block.popResource(level, pos, new ItemStack(CanapliaContent.family(candidate.result()).seeds()));
CanapliaKnowledge.discover(player, candidate.result(), "harvest");
if (candidate.result() == CanapliaVariantKey.LUNAR) {
level.sendParticles(ParticleTypes.END_ROD, pos.getX() + 0.5D, pos.getY() + 0.8D,
pos.getZ() + 0.5D, 6, 0.2D, 0.25D, 0.2D, 0.01D);
level.playSound(null, pos, SoundEvents.AMETHYST_BLOCK_CHIME,
SoundSource.BLOCKS, 0.45F, 1.45F);
}
});
}
private static Set<CanapliaVariantKey> matureNeighbors(ServerLevel level, BlockPos center) {
EnumSet<CanapliaVariantKey> variants = EnumSet.noneOf(CanapliaVariantKey.class);
int radius = CanapliaHarvestRules.NEIGHBOR_RADIUS;
for (BlockPos pos : BlockPos.betweenClosed(center.offset(-radius, -1, -radius), center.offset(radius, 1, radius))) {
if (pos.equals(center)) continue;
BlockState state = level.getBlockState(pos);
if (state.is(CanapliaContent.HEMP_CROP) && CanapliaContent.HEMP_CROP.isMaxAge(state)) {
variants.add(state.getValue(CanapliaHempCropBlock.VARIANT));
}
}
return Set.copyOf(variants);
}
private static boolean touchingWater(ServerLevel level, BlockPos pos) {
for (Direction direction : Direction.values()) {
if (!level.getFluidState(pos.relative(direction)).isEmpty()) return true;
}
return false;
}
}
@@ -0,0 +1,100 @@
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 {
private CanapliaKnowledge() {}
public static boolean discover(ServerPlayer player, CanapliaVariantKey variant, String reason) {
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
if (!data.discover(player.getUUID(), variant.key())) return false;
SanctuaryProgressionService.recordExternal(player,
CanapliaLegacyCatalog.id("discover/" + variant.key()), 1, "canaplia");
player.sendOverlayMessage(Component.translatable("message.canaplia.discovered",
Component.translatable("variant.canaplia." + variant.key())));
return true;
}
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(personalOwner);
int personalMask = mask(personal);
int sharedMask = mask(shared);
int originHints = 0;
int conditionHints = 0;
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
CanapliaOrigin origin = CanapliaVariants.definition(variant).origin();
boolean knowsFamily = personal.stream().map(CanapliaVariantKey::byKey)
.flatMap(java.util.Optional::stream)
.anyMatch(known -> CanapliaVariants.definition(known).origin() == origin);
if (knowsFamily || personal.contains(variant.key())) originHints |= 1 << variant.ordinal();
Set<CanapliaVariantKey> parents = CanapliaHarvestRules.parentsFor(variant);
if (!parents.isEmpty() && parents.stream().allMatch(parent -> personal.contains(parent.key()))) {
conditionHints |= 1 << variant.ordinal();
}
}
ServerPlayNetworking.send(player, new CanapliaCodexPayload(
CanapliaCodexPayload.PROTOCOL_VERSION, personalMask, sharedMask, originHints, conditionHints));
}
private static int mask(Set<String> keys) {
int mask = 0;
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
if (keys.contains(variant.key())) mask |= 1 << variant.ordinal();
}
return mask;
}
}
@@ -0,0 +1,96 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** 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(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Codec.unboundedMap(Codec.STRING, Codec.STRING.listOf()).optionalFieldOf("players", Map.of())
.forGetter(CanapliaKnowledgeData::serializedPlayers),
Codec.STRING.listOf().optionalFieldOf("shared", List.of()).forGetter(data -> List.copyOf(data.shared))
).apply(instance, CanapliaKnowledgeData::new));
private static final SavedDataType<CanapliaKnowledgeData> TYPE = new SavedDataType<>(
CanapliaLegacyCatalog.id("codex"), CanapliaKnowledgeData::new, CODEC, null);
private int dataVersion;
private final Map<String, LinkedHashSet<String>> players = new HashMap<>();
private final LinkedHashSet<String> shared = new LinkedHashSet<>();
public CanapliaKnowledgeData() {
this(DATA_VERSION, Map.of(), List.of());
}
private CanapliaKnowledgeData(int dataVersion, Map<String, List<String>> players, List<String> shared) {
this.dataVersion = Math.max(1, dataVersion);
players.forEach((player, values) -> {
if (isUuid(player)) this.players.put(player, filtered(values));
});
this.shared.addAll(filtered(shared));
}
public static CanapliaKnowledgeData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("minecraft:overworld is unavailable");
CanapliaKnowledgeData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) {
data.dataVersion = DATA_VERSION;
data.setDirty();
}
return data;
}
public boolean discover(UUID player, String key) {
if (!validKey(key)) return false;
boolean personal = players.computeIfAbsent(player.toString(), ignored -> new LinkedHashSet<>()).add(key);
if (personal) setDirty();
return personal;
}
public Set<String> personal(UUID player) {
return Set.copyOf(players.getOrDefault(player.toString(), new LinkedHashSet<>()));
}
public Set<String> shared() {
return Set.copyOf(shared);
}
private Map<String, List<String>> serializedPlayers() {
Map<String, List<String>> result = new HashMap<>();
players.forEach((player, values) -> result.put(player, new ArrayList<>(values)));
return Map.copyOf(result);
}
private static LinkedHashSet<String> filtered(List<String> values) {
LinkedHashSet<String> result = new LinkedHashSet<>();
values.stream().filter(CanapliaKnowledgeData::validKey).limit(256).forEach(result::add);
return result;
}
private static boolean validKey(String value) {
return value != null && value.length() <= 32 && value.matches("[a-z0-9_.-]+");
}
private static boolean isUuid(String value) {
try { UUID.fromString(value); return true; }
catch (IllegalArgumentException ignored) { return false; }
}
static Codec<CanapliaKnowledgeData> codecForTests() {
return CODEC;
}
}
@@ -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;
}
}
@@ -0,0 +1,81 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.resources.Identifier;
/**
* Compatibility inventory for the identifiers emitted by Canaplia 2.0.
* Registration is deliberately left to CANA-02 and later gameplay tickets.
*/
public final class CanapliaLegacyCatalog {
public static final String NAMESPACE = "canaplia";
public static final List<String> ITEM_FAMILIES = List.of(
"hemp_seeds",
"hemp_bud",
"resin_block",
"rolled_joint"
);
public static final List<Identifier> BLOCK_IDS = ids("hemp_crop", "hemp_top", "resin_block");
public static final List<Identifier> BLOCK_ENTITY_TYPE_IDS = ids("resin_block");
public static final List<Identifier> SAVED_DATA_IDS = ids("hemp_bud_composters");
public static final List<Identifier> PAYLOAD_IDS = ids(
"hemp_visual_sync",
"hemp_bad_trip",
"hemp_visual_clear"
);
public static final List<Identifier> LOOT_TABLE_IDS = ids(
"blocks/hemp_crop",
"blocks/hemp_top",
"blocks/resin_block"
);
public static final List<Identifier> ITEM_IDS = createFamilyIds(ITEM_FAMILIES);
public static final List<Identifier> RECIPE_IDS = createFamilyIds(List.of("rolled_joint"));
private CanapliaLegacyCatalog() {
}
public static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(NAMESPACE, path);
}
public static List<String> variantKeys() {
return List.of(CanapliaVariantKey.values()).stream().map(CanapliaVariantKey::key).toList();
}
public static void validate() {
requireSize("variants", variantKeys(), 16);
requireSize("blocks", BLOCK_IDS, 3);
requireSize("block entity types", BLOCK_ENTITY_TYPE_IDS, 1);
requireSize("items", ITEM_IDS, 64);
requireSize("saved data", SAVED_DATA_IDS, 1);
requireSize("payloads", PAYLOAD_IDS, 3);
requireSize("loot tables", LOOT_TABLE_IDS, 3);
requireSize("recipes", RECIPE_IDS, 16);
}
private static List<Identifier> createFamilyIds(List<String> families) {
List<Identifier> identifiers = new ArrayList<>(CanapliaVariantKey.values().length * families.size());
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
for (String family : families) {
identifiers.add(id(variant.legacyItemPath(family)));
}
}
return List.copyOf(identifiers);
}
private static List<Identifier> ids(String... paths) {
return java.util.Arrays.stream(paths).map(CanapliaLegacyCatalog::id).toList();
}
private static void requireSize(String label, List<?> values, int expectedSize) {
Set<?> unique = new HashSet<>(values);
if (values.size() != expectedSize || unique.size() != expectedSize) {
throw new IllegalStateException(
"Invalid Canaplia " + label + " catalog: expected " + expectedSize + " unique values, got " + values
);
}
}
}
@@ -0,0 +1,11 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
/** How a Canaplia variety enters a player's collection. */
public enum CanapliaOrigin {
WILD,
HYBRID,
MUTATION,
LUNAR,
NETHER,
END
}
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
/** Immutable catalogue entry shared by content, effects and the Codex. */
public record CanapliaVariantDefinition(
CanapliaVariantKey key,
CanapliaOrigin origin,
int color,
float saturation,
float motion
) {
public CanapliaVariantDefinition {
if (key == null || origin == null) throw new IllegalArgumentException("Canaplia definition is incomplete");
color &= 0xFFFFFF;
saturation = Math.clamp(saturation, -1.0F, 1.0F);
motion = Math.clamp(motion, 0.0F, 1.0F);
}
public AmbianceVisualProfile normalVisual() {
return visual(0.36F, 0.12F, 0.05F, motion * 0.20F, motion * 0.12F, 0.05F);
}
public AmbianceVisualProfile badTripVisual() {
return visual(0.78F, 0.34F, 0.24F, Math.max(0.32F, motion), Math.max(0.26F, motion), 0.30F);
}
private AmbianceVisualProfile visual(float intensity, float tint, float vignette,
float ghosting, float wobble, float pulse) {
float red = ((color >> 16) & 0xFF) / 255.0F;
float green = ((color >> 8) & 0xFF) / 255.0F;
float blue = (color & 0xFF) / 255.0F;
return new AmbianceVisualProfile(intensity, red, green, blue, tint,
saturation, 0.04F, vignette, ghosting, wobble, pulse);
}
}
@@ -0,0 +1,48 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import java.util.Arrays;
import java.util.Optional;
import net.minecraft.util.StringRepresentable;
/** Stable machine keys from Canaplia 2.0. */
public enum CanapliaVariantKey implements StringRepresentable {
COMMON("common"),
TENDER("tender"),
OLIVE("olive"),
DARK("dark"),
ASHEN("ashen"),
MOSS("moss"),
GOLD("gold"),
AMBER("amber"),
BLUISH("bluish"),
PINK("pink"),
CORAL("coral"),
PURPLE("purple"),
INDIGO("indigo"),
VARIEG("varieg"),
LUNAR("lunar"),
EMBER("ember");
private final String key;
CanapliaVariantKey(String key) {
this.key = key;
}
public String key() {
return this.key;
}
@Override
public String getSerializedName() {
return this.key;
}
public String legacyItemPath(String family) {
return this == COMMON ? family : this.key + "_" + family;
}
public static Optional<CanapliaVariantKey> byKey(String key) {
return Arrays.stream(values()).filter(variant -> variant.key.equals(key)).findFirst();
}
}
@@ -0,0 +1,54 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/** Canonical sixteen-entry gameplay and visual catalogue. */
public final class CanapliaVariants {
private static final Map<CanapliaVariantKey, CanapliaVariantDefinition> DEFINITIONS = definitions();
private CanapliaVariants() {}
public static CanapliaVariantDefinition definition(CanapliaVariantKey key) {
CanapliaVariantDefinition definition = DEFINITIONS.get(key);
if (definition == null) throw new IllegalArgumentException("Unknown Canaplia variant " + key);
return definition;
}
public static List<CanapliaVariantDefinition> all() {
return List.of(CanapliaVariantKey.values()).stream().map(CanapliaVariants::definition).toList();
}
public static void validate() {
if (DEFINITIONS.size() != CanapliaVariantKey.values().length || DEFINITIONS.size() != 16) {
throw new IllegalStateException("Canaplia must define exactly sixteen variants");
}
}
private static Map<CanapliaVariantKey, CanapliaVariantDefinition> definitions() {
EnumMap<CanapliaVariantKey, CanapliaVariantDefinition> values = new EnumMap<>(CanapliaVariantKey.class);
put(values, CanapliaVariantKey.COMMON, CanapliaOrigin.WILD, 0x5F9E55, 0.08F, 0.10F);
put(values, CanapliaVariantKey.TENDER, CanapliaOrigin.WILD, 0xA7D88B, 0.14F, 0.08F);
put(values, CanapliaVariantKey.OLIVE, CanapliaOrigin.WILD, 0x8A8C3B, -0.04F, 0.08F);
put(values, CanapliaVariantKey.DARK, CanapliaOrigin.WILD, 0x384A42, -0.18F, 0.16F);
put(values, CanapliaVariantKey.ASHEN, CanapliaOrigin.WILD, 0xA9B5B5, -0.12F, 0.06F);
put(values, CanapliaVariantKey.MOSS, CanapliaOrigin.WILD, 0x397B4B, 0.10F, 0.10F);
put(values, CanapliaVariantKey.GOLD, CanapliaOrigin.HYBRID, 0xE4B83E, 0.24F, 0.14F);
put(values, CanapliaVariantKey.AMBER, CanapliaOrigin.WILD, 0xD57A2E, 0.18F, 0.16F);
put(values, CanapliaVariantKey.BLUISH, CanapliaOrigin.HYBRID, 0x64A8C8, 0.12F, 0.10F);
put(values, CanapliaVariantKey.PINK, CanapliaOrigin.HYBRID, 0xE889A8, 0.18F, 0.16F);
put(values, CanapliaVariantKey.CORAL, CanapliaOrigin.HYBRID, 0xE16E62, 0.20F, 0.18F);
put(values, CanapliaVariantKey.PURPLE, CanapliaOrigin.HYBRID, 0x8154A8, 0.12F, 0.22F);
put(values, CanapliaVariantKey.INDIGO, CanapliaOrigin.END, 0x494E9C, 0.08F, 0.24F);
put(values, CanapliaVariantKey.VARIEG, CanapliaOrigin.MUTATION, 0xD6E26C, 0.28F, 0.22F);
put(values, CanapliaVariantKey.LUNAR, CanapliaOrigin.LUNAR, 0xC7D8F2, -0.02F, 0.18F);
put(values, CanapliaVariantKey.EMBER, CanapliaOrigin.NETHER, 0xD94B31, 0.26F, 0.24F);
return Map.copyOf(values);
}
private static void put(Map<CanapliaVariantKey, CanapliaVariantDefinition> definitions,
CanapliaVariantKey key, CanapliaOrigin origin, int color, float saturation, float motion) {
definitions.put(key, new CanapliaVariantDefinition(key, origin, color, saturation, motion));
}
}
@@ -0,0 +1,26 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualEffectApi;
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
/** One-way bridge from It's Alive gameplay state to Ambiance presentation. */
public final class CanapliaVisualContract {
private CanapliaVisualContract() {
}
public static AmbianceVisualEffectApi.Request normal(AmbianceVisualProfile profile, int remainingTicks) {
return AmbianceVisualEffectApi.request(CanapliaLegacyCatalog.PAYLOAD_IDS.get(0), profile, remainingTicks);
}
public static AmbianceVisualEffectApi.Request badTrip(AmbianceVisualProfile profile, int remainingTicks) {
return AmbianceVisualEffectApi.request(CanapliaLegacyCatalog.PAYLOAD_IDS.get(1), profile, remainingTicks);
}
public static AmbianceVisualEffectApi.Request clear() {
return AmbianceVisualEffectApi.request(
CanapliaLegacyCatalog.PAYLOAD_IDS.get(2),
AmbianceVisualProfile.NONE,
0
);
}
}
@@ -0,0 +1,112 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaHarvestService;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import net.minecraft.core.BlockPos;
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.util.RandomSource;
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.item.Items;
import net.minecraft.world.level.ItemLike;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.CropBlock;
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.EnumProperty;
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 = 7;
private static final int HARVEST_RESET_AGE = 3;
public CanapliaHempCropBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(defaultBlockState().setValue(VARIANT, CanapliaVariantKey.COMMON));
}
@Override
protected ItemLike getBaseSeedId() {
return CanapliaContent.family(CanapliaVariantKey.COMMON).seeds();
}
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(VARIANT)).seeds());
}
@Override
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
super.randomTick(state, level, pos, random);
synchronizeUpper(level, pos, level.getBlockState(pos));
}
@Override
public void growCrops(Level level, BlockPos pos, BlockState state) {
int age = Math.min(getMaxAge(), getAge(state) + getBonemealAgeIncrease(level));
BlockState grown = state.setValue(AGE, age);
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, grown);
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (stack.is(Items.BONE_MEAL) || !isMaxAge(state)) return InteractionResult.PASS;
return harvest(level, pos, state, player);
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
return isMaxAge(state) ? harvest(level, pos, state, player) : InteractionResult.PASS;
}
private InteractionResult harvest(Level level, BlockPos pos, BlockState state, Player player) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(level instanceof ServerLevel serverLevel) || !(player instanceof ServerPlayer serverPlayer)) {
return InteractionResult.FAIL;
}
CanapliaVariantKey variant = state.getValue(VARIANT);
Block.popResource(serverLevel, pos,
new ItemStack(CanapliaContent.family(variant).bud(), 1 + serverLevel.getRandom().nextInt(3)));
CanapliaHarvestService.onMatureHarvest(serverLevel, serverPlayer, pos, state);
BlockState reset = state.setValue(AGE, HARVEST_RESET_AGE);
level.setBlock(pos, reset, Block.UPDATE_CLIENTS);
synchronizeUpper(level, pos, reset);
level.playSound(null, pos, SoundEvents.SWEET_BERRY_BUSH_PICK_BERRIES,
SoundSource.BLOCKS, 0.8F, 0.9F + level.getRandom().nextFloat() * 0.2F);
return InteractionResult.SUCCESS_SERVER;
}
public void synchronizeUpper(Level level, BlockPos pos, BlockState state) {
if (!state.is(this)) return;
BlockPos above = pos.above();
BlockState aboveState = level.getBlockState(above);
if (getAge(state) >= UPPER_START_AGE) {
if (aboveState.isAir() || aboveState.is(CanapliaContent.HEMP_TOP)) {
level.setBlock(above, CanapliaContent.HEMP_TOP.defaultBlockState()
.setValue(AGE, getAge(state)).setValue(VARIANT, state.getValue(VARIANT)), Block.UPDATE_CLIENTS);
}
} else if (aboveState.is(CanapliaContent.HEMP_TOP)) {
level.removeBlock(above, false);
}
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
super.createBlockStateDefinition(builder);
builder.add(VARIANT);
}
}
@@ -0,0 +1,48 @@
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.world.item.ItemStack;
import net.minecraft.world.level.ItemLike;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.CropBlock;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
/** Non-plantable upper half synchronized from the authoritative lower crop. */
public final class CanapliaHempTopBlock extends CropBlock {
public CanapliaHempTopBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(defaultBlockState().setValue(CanapliaHempCropBlock.VARIANT, CanapliaVariantKey.COMMON));
}
@Override
protected ItemLike getBaseSeedId() {
return CanapliaContent.family(CanapliaVariantKey.COMMON).seeds();
}
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return new ItemStack(CanapliaContent.family(state.getValue(CanapliaHempCropBlock.VARIANT)).seeds());
}
@Override
protected boolean mayPlaceOn(BlockState state, net.minecraft.world.level.BlockGetter level, BlockPos pos) {
return state.is(CanapliaContent.HEMP_CROP)
&& state.getValue(AGE) >= CanapliaHempCropBlock.UPPER_START_AGE;
}
@Override
protected boolean isRandomlyTicking(BlockState state) {
return false;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
super.createBlockStateDefinition(builder);
builder.add(CanapliaHempCropBlock.VARIANT);
}
}
@@ -0,0 +1,140 @@
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;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
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(LIT, false)
.setValue(VARIANT, CanapliaVariantKey.COMMON));
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new CanapliaResinBlockEntity(pos, state);
}
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> 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());
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(LIT, VARIANT);
}
}
@@ -0,0 +1,93 @@
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 = 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;
private boolean migrateLegacyBurn;
public CanapliaResinBlockEntity(BlockPos pos, BlockState state) {
super(CanapliaContent.RESIN_BLOCK_ENTITY_TYPE, pos, state);
}
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) {
extinguishAndConsume(serverLevel, pos, state);
return;
}
if (entity.remainingBurnTicks % AURA_INTERVAL == 0) {
AABB area = new AABB(pos).inflate(AURA_RADIUS);
for (ServerPlayer player : serverLevel.getEntitiesOfClass(ServerPlayer.class, area)) {
CanapliaEffects.refreshAura(player, state.getValue(CanapliaResinBlock.VARIANT));
}
}
entity.remainingBurnTicks--;
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;
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt("data_version", DATA_VERSION);
output.putInt("remaining_burn_ticks", remainingBurnTicks);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
int dataVersion = input.getIntOr("data_version", 1);
remainingBurnTicks = Math.clamp(
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));
}
}
@@ -0,0 +1,58 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.client;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
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;
/** Vanilla book presentation of the server-authored Canaplia catalogue. */
public final class CanapliaCodexScreen extends BookViewScreen {
public CanapliaCodexScreen(CanapliaCodexPayload snapshot) {
super(new BookAccess(pages(snapshot)));
}
private static List<Component> pages(CanapliaCodexPayload snapshot) {
List<Component> pages = new ArrayList<>(CanapliaVariantKey.values().length);
CanapliaVariantKey[] variants = CanapliaVariantKey.values();
for (int index = 0; index < variants.length; index++) {
pages.add(variantPage(snapshot, variants[index], index));
}
return List.copyOf(pages);
}
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);
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));
}
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;
}
}
@@ -0,0 +1,36 @@
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. */
public final class CanapliaCodexItem extends Item {
public CanapliaCodexItem(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) 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;
}
}
@@ -0,0 +1,67 @@
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;
/** Consumable form whose gameplay outcome is chosen exclusively by the server. */
public final class CanapliaJointItem extends Item {
private static final int USE_TICKS = 36;
private final CanapliaVariantKey variant;
public CanapliaJointItem(CanapliaVariantKey variant, Properties properties) {
super(properties);
this.variant = variant;
}
public CanapliaVariantKey variant() {
return variant;
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
return ItemUtils.startUsingInstantly(level, player, hand);
}
@Override
public ItemStack finishUsingItem(ItemStack stack, Level level, LivingEntity consumer) {
if (!level.isClientSide() && consumer instanceof ServerPlayer player) {
CanapliaKnowledge.discover(player, variant, "consumption");
CanapliaEffects.consume(player, variant);
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.DRINK;
}
@Override
public int getUseDuration(ItemStack stack, LivingEntity entity) {
return USE_TICKS;
}
}
@@ -0,0 +1,36 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** One historical item ID placing a shared block with an explicit stable variant state. */
public final class CanapliaVariantBlockItem extends BlockItem {
private final CanapliaVariantKey variant;
public CanapliaVariantBlockItem(Block block, CanapliaVariantKey variant, Properties properties) {
super(block, properties);
this.variant = variant;
}
public CanapliaVariantKey variant() {
return variant;
}
@Override
protected BlockState getPlacementState(BlockPlaceContext context) {
BlockState state = super.getPlacementState(context);
if (state == null) return null;
if (state.hasProperty(CanapliaHempCropBlock.VARIANT)) {
return state.setValue(CanapliaHempCropBlock.VARIANT, variant);
}
if (state.hasProperty(CanapliaResinBlock.VARIANT)) {
return state.setValue(CanapliaResinBlock.VARIANT, variant);
}
return state;
}
}
@@ -25,6 +25,8 @@ import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveEntityTypes;
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveFoodProcessing;
import fr.koka99cab.sanctuary26.itsalive.network.VomiterOverlayPayload;
import fr.koka99cab.sanctuary26.itsalive.network.CulinaryRecipeBookPayload;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import fr.koka99cab.sanctuary26.itsalive.canaplia.client.CanapliaCodexScreen;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
@@ -48,6 +50,9 @@ public final class ItsAliveClient implements ClientModInitializer {
context.client().execute(() -> VomiterOverlayState.update(payload.remainingTicks())));
ClientPlayNetworking.registerGlobalReceiver(CulinaryRecipeBookPayload.ID, (payload, context) ->
context.client().execute(() -> CulinaryRecipeBookClient.open(payload)));
ClientPlayNetworking.registerGlobalReceiver(CanapliaCodexPayload.ID, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
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);
@@ -0,0 +1,46 @@
package fr.koka99cab.sanctuary26.itsalive.network;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaLegacyCatalog;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
/** Bounded sixteen-bit knowledge snapshot; no client-to-server discovery payload exists. */
public record CanapliaCodexPayload(
int protocol,
int personalMask,
int sharedMask,
int originHintMask,
int conditionHintMask
) implements CustomPacketPayload {
public static final int PROTOCOL_VERSION = 1;
public static final int VARIANT_MASK = 0xFFFF;
public static final Type<CanapliaCodexPayload> ID = new Type<>(CanapliaLegacyCatalog.id("codex_sync"));
public static final StreamCodec<RegistryFriendlyByteBuf, CanapliaCodexPayload> CODEC =
CustomPacketPayload.codec(CanapliaCodexPayload::write, CanapliaCodexPayload::new);
public CanapliaCodexPayload {
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
personalMask &= VARIANT_MASK;
sharedMask &= VARIANT_MASK;
originHintMask &= VARIANT_MASK;
conditionHintMask &= VARIANT_MASK;
}
private CanapliaCodexPayload(RegistryFriendlyByteBuf buffer) {
this(buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt(), buffer.readVarInt());
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(protocol);
buffer.writeVarInt(personalMask);
buffer.writeVarInt(sharedMask);
buffer.writeVarInt(originHintMask);
buffer.writeVarInt(conditionHintMask);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return ID;
}
}
@@ -7,7 +7,7 @@ import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
* Networking contract for It's Alive !. Payload IDs must use the module namespace.
*/
public final class ItsAliveNetworking {
public static final int PROTOCOL_VERSION = 3;
public static final int PROTOCOL_VERSION = 4;
private static boolean initialized;
private ItsAliveNetworking() {
@@ -21,7 +21,8 @@ public final class ItsAliveNetworking {
PayloadTypeRegistry.clientboundPlay().register(VomiterOverlayPayload.ID, VomiterOverlayPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(
CulinaryRecipeBookPayload.ID, CulinaryRecipeBookPayload.CODEC);
ItsAliveMod.LOGGER.debug("[{}] Networking contract v{} ready; overlays and culinary book registered.",
PayloadTypeRegistry.clientboundPlay().register(CanapliaCodexPayload.ID, CanapliaCodexPayload.CODEC);
ItsAliveMod.LOGGER.debug("[{}] Networking contract v{} ready; overlays, culinary book and Canaplia Codex registered.",
ItsAliveMod.DISPLAY_NAME, PROTOCOL_VERSION);
}
}
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive.registry;
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
import java.util.LinkedHashSet;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -29,6 +30,7 @@ public final class ItsAliveCreativeTab {
items.add(ItsAliveItems.LEMON_LEAVES);
items.addAll(ItsAliveItems.PRODUCE_ITEMS);
items.addAll(ItsAliveFoodProcessing.creativeItems());
items.addAll(CanapliaContent.creativeItems());
items.forEach(output::accept);
})
.build()
@@ -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"),

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