Compare commits

..
Author SHA1 Message Date
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
321 changed files with 5770 additions and 407 deletions
+58
View File
@@ -0,0 +1,58 @@
# 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 de relier le Codex autour d'un livre. L'objet utilise la texture du livre Minecraft et son écran repose sur les pages vanilla. Il distingue les découvertes personnelles des silhouettes connues par la communauté ; les origines génériques et les conditions détaillées se révèlent progressivement sans envoyer de payload de découverte client-vers-serveur.
Les données canaplia:codex sont versionnées. Les clés futures syntaxiquement valides restent conservées lors d'un aller-retour afin qu'une migration ne perde pas les découvertes ajoutées par une version ultérieure.
## 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`.
+62 -2
View File
@@ -6,13 +6,73 @@ 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
## `26.2.0-alpha.209`
Module modifié : **Another World `0.0.0-alpha.52`**. Le pack distribué reste en **`26.2.0-alpha.203`**.
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`
+14 -4
View File
@@ -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, 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.208`**. Cette release distribue Canaplia complet avec It's Alive **`0.0.0-alpha.33`**, Ambiance **`0.0.0-alpha.14`** et Only Fun **`0.0.0-alpha.20`**.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.33`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Codex. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.20`** exclut explicitement Canaplia du Shop et des récompenses ordinaires. Ces trois modules sont inclus dans l'alpha.208 pour les clients et les serveurs.
Lalpha.208 ajoute Canaplia aux packs Modrinth, Prism et Packwiz sans exiger de nouveau monde. Les sources sauvages fonctionnent dans les chunks existants, tandis que les nouvelles données de compostage et de Codex sont créées ou migrées automatiquement à leur première utilisation.
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.
+11 -2
View File
@@ -5,6 +5,14 @@ dependencies {
compileOnly "maven.modrinth:YL57xq9U:oaD6KQls"
}
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) {
group = "verification"
description = "Checks deterministic daily weather and the native eight-day lunar schedule."
@@ -61,7 +69,7 @@ tasks.register("verifyAmbiance") {
[(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.12"
if (project.version.toString() != "0.0.0-alpha.14"
|| 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 +84,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 != 5
|| 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"
@@ -304,4 +312,5 @@ tasks.register("verifyAmbiance") {
tasks.named("check") {
dependsOn tasks.named("verifyAmbiance")
dependsOn tasks.named("ambianceScheduleSmoke")
dependsOn tasks.named("ambianceVisualApiSmoke")
}
@@ -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,32 @@
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 net.fabricmc.api.ClientModInitializer;
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 +35,27 @@ 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));
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();
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();
});
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,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,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 = 5;
private static boolean initialized;
private AmbianceNetworking() {
@@ -28,6 +28,12 @@ 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);
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
send(handler.player, AmbianceEnvironmentController.snapshot(server))
);
@@ -4,6 +4,7 @@
"package": "fr.koka99cab.sanctuary26.ambiance.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"CameraMixin",
"DynamicEntityRendererMixin",
"DynamicItemInHandRendererMixin",
"FogRendererMixin",
@@ -17,5 +17,12 @@
"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"
}
@@ -17,5 +17,12 @@
"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"
}
@@ -17,5 +17,12 @@
"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": "Настройки визуальной доступности"
}
+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, 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": 5,
"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, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
"en_us": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.",
"ru_ru": "Управляет атмосферой, динамическим освещением, календарём и доступными составными визуальными эффектами, включая Canaplia."
}
}
}
@@ -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);
}
}
}
+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");
+819 -86
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -12,13 +12,13 @@ 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.14
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.34
itsalive_lifecycle=active
ouch_version=0.0.0-alpha.9
ouch_lifecycle=active
@@ -26,9 +26,9 @@ masterkey_version=0.0.0-alpha.8
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.14
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.19
onlyfun_version=0.0.0-alpha.20
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.117
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.203
pack_version=26.2.0-alpha.209
maven_group=fr.koka99cab.sanctuary26
+148 -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/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.34"
|| 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.34"
|| 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.34"
|| 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.34"
|| 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.34"
|| 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
@@ -1324,6 +1463,7 @@ tasks.register("verifyCulinaryLecternRelease") {
tasks.named("check") {
dependsOn tasks.named("verifyItsAlive")
dependsOn tasks.named("canapliaFoundationSmoke")
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,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,131 @@
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.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 Item(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,39 @@
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();
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,55 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService;
import java.util.Set;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
/** 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) {
if (!ServerPlayNetworking.canSend(player, CanapliaCodexPayload.ID)) return;
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
Set<String> personal = data.personal(player.getUUID());
Set<String> shared = data.shared();
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,97 @@
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 and shared discoveries; unknown future keys survive round-trips. */
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);
boolean community = shared.add(key);
if (personal || community) 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,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,59 @@
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 + 1);
pages.add(Component.translatable("screen.canaplia.codex.introduction"));
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((personal
? Component.translatable("variant.canaplia." + variant.key())
: Component.translatable("screen.canaplia.codex.silhouette"))
.withStyle(personal ? ChatFormatting.DARK_GREEN : ChatFormatting.GRAY));
if (personal || originHint) {
page.append("\n\n").append(Component.translatable("screen.canaplia.codex.origin",
Component.translatable("origin.canaplia." + variant.key())));
}
if (personal || conditionHint) {
page.append("\n\n").append(Component.translatable("hint.canaplia." + variant.key()));
}
return page;
}
private static boolean bit(int mask, int index) {
return (mask & (1 << index)) != 0;
}
}
@@ -0,0 +1,23 @@
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.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;
}
}
@@ -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()
@@ -0,0 +1,130 @@
{
"description.canaplia": "Complete Canaplia gameplay: sixteen plants, server-authoritative discovery, hybrids, effects and an Ambiance-owned visual layer.",
"screen.canaplia.codex.title": "Canaplia Codex",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.introduction": "CANAPLIA CODEX\n\nSlay monsters to recover scattered pages and, very rarely, a complete Codex.\n\nGrow and discover varieties to fill its pages.",
"screen.canaplia.codex.entry_number": "Entry %s",
"screen.canaplia.codex.origin": "Origin: %s",
"screen.canaplia.codex.hidden": "Undiscovered",
"screen.canaplia.codex.silhouette": "Known silhouette",
"message.canaplia.discovered": "Canaplia discovered: %s",
"message.canaplia.composter.mixed": "This composter already contains another Canaplia variant.",
"block.canaplia.hemp_crop": "Canaplia Plant",
"block.canaplia.hemp_top": "Canaplia Plant Top",
"block.canaplia.resin_block": "Canaplia Resin Block",
"item.canaplia.codex": "Canaplia Codex",
"item.canaplia.codex_page": "Canaplia Page",
"variant.canaplia.common": "Common",
"origin.canaplia.common": "Wild",
"hint.canaplia.common": "Search ordinary Overworld vegetation.",
"item.canaplia.hemp_seeds": "Common Hemp Seeds",
"item.canaplia.hemp_bud": "Common Hemp Bud",
"item.canaplia.resin_block": "Common Resin Block",
"item.canaplia.rolled_joint": "Common Rolled Joint",
"variant.canaplia.tender": "Tender",
"origin.canaplia.tender": "Wild",
"hint.canaplia.tender": "Search flowers in mild meadows.",
"item.canaplia.tender_hemp_seeds": "Tender Hemp Seeds",
"item.canaplia.tender_hemp_bud": "Tender Hemp Bud",
"item.canaplia.tender_resin_block": "Tender Resin Block",
"item.canaplia.tender_rolled_joint": "Tender Rolled Joint",
"variant.canaplia.olive": "Olive",
"origin.canaplia.olive": "Wild",
"hint.canaplia.olive": "Search dry plants in warm places.",
"item.canaplia.olive_hemp_seeds": "Olive Hemp Seeds",
"item.canaplia.olive_hemp_bud": "Olive Hemp Bud",
"item.canaplia.olive_resin_block": "Olive Resin Block",
"item.canaplia.olive_rolled_joint": "Olive Rolled Joint",
"variant.canaplia.dark": "Dark",
"origin.canaplia.dark": "Wild",
"hint.canaplia.dark": "Search plants beneath a dark canopy.",
"item.canaplia.dark_hemp_seeds": "Dark Hemp Seeds",
"item.canaplia.dark_hemp_bud": "Dark Hemp Bud",
"item.canaplia.dark_resin_block": "Dark Resin Block",
"item.canaplia.dark_rolled_joint": "Dark Rolled Joint",
"variant.canaplia.ashen": "Ashen",
"origin.canaplia.ashen": "Wild",
"hint.canaplia.ashen": "Search plants in cold regions.",
"item.canaplia.ashen_hemp_seeds": "Ashen Hemp Seeds",
"item.canaplia.ashen_hemp_bud": "Ashen Hemp Bud",
"item.canaplia.ashen_resin_block": "Ashen Resin Block",
"item.canaplia.ashen_rolled_joint": "Ashen Rolled Joint",
"variant.canaplia.moss": "Moss",
"origin.canaplia.moss": "Wild",
"hint.canaplia.moss": "Search plants in humid or lush regions.",
"item.canaplia.moss_hemp_seeds": "Moss Hemp Seeds",
"item.canaplia.moss_hemp_bud": "Moss Hemp Bud",
"item.canaplia.moss_resin_block": "Moss Resin Block",
"item.canaplia.moss_rolled_joint": "Moss Rolled Joint",
"variant.canaplia.gold": "Gold",
"origin.canaplia.gold": "Hybrid",
"hint.canaplia.gold": "Harvest Tender beside Olive.",
"item.canaplia.gold_hemp_seeds": "Gold Hemp Seeds",
"item.canaplia.gold_hemp_bud": "Gold Hemp Bud",
"item.canaplia.gold_resin_block": "Gold Resin Block",
"item.canaplia.gold_rolled_joint": "Gold Rolled Joint",
"variant.canaplia.amber": "Amber",
"origin.canaplia.amber": "Wild",
"hint.canaplia.amber": "Search badlands vegetation; this form is rare.",
"item.canaplia.amber_hemp_seeds": "Amber Hemp Seeds",
"item.canaplia.amber_hemp_bud": "Amber Hemp Bud",
"item.canaplia.amber_resin_block": "Amber Resin Block",
"item.canaplia.amber_rolled_joint": "Amber Rolled Joint",
"variant.canaplia.bluish": "Bluish",
"origin.canaplia.bluish": "Hybrid",
"hint.canaplia.bluish": "Harvest Ashen beside Tender in the cold.",
"item.canaplia.bluish_hemp_seeds": "Bluish Hemp Seeds",
"item.canaplia.bluish_hemp_bud": "Bluish Hemp Bud",
"item.canaplia.bluish_resin_block": "Bluish Resin Block",
"item.canaplia.bluish_rolled_joint": "Bluish Rolled Joint",
"variant.canaplia.pink": "Pink",
"origin.canaplia.pink": "Hybrid",
"hint.canaplia.pink": "Harvest Tender beside Purple at twilight.",
"item.canaplia.pink_hemp_seeds": "Pink Hemp Seeds",
"item.canaplia.pink_hemp_bud": "Pink Hemp Bud",
"item.canaplia.pink_resin_block": "Pink Resin Block",
"item.canaplia.pink_rolled_joint": "Pink Rolled Joint",
"variant.canaplia.coral": "Coral",
"origin.canaplia.coral": "Hybrid",
"hint.canaplia.coral": "Harvest Amber beside Moss near water.",
"item.canaplia.coral_hemp_seeds": "Coral Hemp Seeds",
"item.canaplia.coral_hemp_bud": "Coral Hemp Bud",
"item.canaplia.coral_resin_block": "Coral Resin Block",
"item.canaplia.coral_rolled_joint": "Coral Rolled Joint",
"variant.canaplia.purple": "Purple",
"origin.canaplia.purple": "Hybrid",
"hint.canaplia.purple": "Harvest Dark beside Moss in low, humid light.",
"item.canaplia.purple_hemp_seeds": "Purple Hemp Seeds",
"item.canaplia.purple_hemp_bud": "Purple Hemp Bud",
"item.canaplia.purple_resin_block": "Purple Resin Block",
"item.canaplia.purple_rolled_joint": "Purple Rolled Joint",
"variant.canaplia.indigo": "Indigo",
"origin.canaplia.indigo": "End",
"hint.canaplia.indigo": "Search chorus plants in the End.",
"item.canaplia.indigo_hemp_seeds": "Indigo Hemp Seeds",
"item.canaplia.indigo_hemp_bud": "Indigo Hemp Bud",
"item.canaplia.indigo_resin_block": "Indigo Resin Block",
"item.canaplia.indigo_rolled_joint": "Indigo Rolled Joint",
"variant.canaplia.varieg": "Variegated",
"origin.canaplia.varieg": "Mutation",
"hint.canaplia.varieg": "Mix distinct mature forms in humid air.",
"item.canaplia.varieg_hemp_seeds": "Variegated Hemp Seeds",
"item.canaplia.varieg_hemp_bud": "Variegated Hemp Bud",
"item.canaplia.varieg_resin_block": "Variegated Resin Block",
"item.canaplia.varieg_rolled_joint": "Variegated Rolled Joint",
"variant.canaplia.lunar": "Lunar",
"origin.canaplia.lunar": "Lunar",
"hint.canaplia.lunar": "Harvest mature Ashen or Bluish at night under a cold open sky.",
"item.canaplia.lunar_hemp_seeds": "Lunar Hemp Seeds",
"item.canaplia.lunar_hemp_bud": "Lunar Hemp Bud",
"item.canaplia.lunar_resin_block": "Lunar Resin Block",
"item.canaplia.lunar_rolled_joint": "Lunar Rolled Joint",
"variant.canaplia.ember": "Ember",
"origin.canaplia.ember": "Nether",
"hint.canaplia.ember": "Search native roots, sprouts and vines in the Nether.",
"item.canaplia.ember_hemp_seeds": "Ember Hemp Seeds",
"item.canaplia.ember_hemp_bud": "Ember Hemp Bud",
"item.canaplia.ember_resin_block": "Ember Resin Block",
"item.canaplia.ember_rolled_joint": "Ember Rolled Joint",
"tag.item.canaplia.hemp_buds": "Canaplia Buds"
}
@@ -0,0 +1,130 @@
{
"description.canaplia": "Gameplay Canaplia complet : seize plantes, découvertes, hybrides et effets autoritaires côté serveur, avec une couche visuelle gérée par Ambiance.",
"screen.canaplia.codex.title": "Codex Canaplia",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.introduction": "CODEX CANAPLIA\n\nTuez des monstres pour retrouver des pages éparses et, très rarement, un Codex complet.\n\nCultivez et découvrez les variétés pour remplir ses pages.",
"screen.canaplia.codex.entry_number": "Entrée %s",
"screen.canaplia.codex.origin": "Origine : %s",
"screen.canaplia.codex.hidden": "Non découverte",
"screen.canaplia.codex.silhouette": "Silhouette connue",
"message.canaplia.discovered": "Canaplia découverte : %s",
"message.canaplia.composter.mixed": "Ce composteur contient déjà une autre variante de Canaplia.",
"block.canaplia.hemp_crop": "Plant de Canaplia",
"block.canaplia.hemp_top": "Sommet de Canaplia",
"block.canaplia.resin_block": "Bloc de résine Canaplia",
"item.canaplia.codex": "Codex Canaplia",
"item.canaplia.codex_page": "Page de Canaplia",
"variant.canaplia.common": "Commune",
"origin.canaplia.common": "Sauvage",
"hint.canaplia.common": "Cherchez dans la végétation ordinaire de l'Overworld.",
"item.canaplia.hemp_seeds": "Graines de chanvre — Commune",
"item.canaplia.hemp_bud": "Tête de chanvre — Commune",
"item.canaplia.resin_block": "Bloc de résine — Commune",
"item.canaplia.rolled_joint": "Joint roulé — Commune",
"variant.canaplia.tender": "Tendre",
"origin.canaplia.tender": "Sauvage",
"hint.canaplia.tender": "Cherchez les fleurs des prairies tempérées.",
"item.canaplia.tender_hemp_seeds": "Graines de chanvre — Tendre",
"item.canaplia.tender_hemp_bud": "Tête de chanvre — Tendre",
"item.canaplia.tender_resin_block": "Bloc de résine — Tendre",
"item.canaplia.tender_rolled_joint": "Joint roulé — Tendre",
"variant.canaplia.olive": "Olive",
"origin.canaplia.olive": "Sauvage",
"hint.canaplia.olive": "Cherchez les plantes sèches des régions chaudes.",
"item.canaplia.olive_hemp_seeds": "Graines de chanvre — Olive",
"item.canaplia.olive_hemp_bud": "Tête de chanvre — Olive",
"item.canaplia.olive_resin_block": "Bloc de résine — Olive",
"item.canaplia.olive_rolled_joint": "Joint roulé — Olive",
"variant.canaplia.dark": "Sombre",
"origin.canaplia.dark": "Sauvage",
"hint.canaplia.dark": "Cherchez sous une canopée sombre.",
"item.canaplia.dark_hemp_seeds": "Graines de chanvre — Sombre",
"item.canaplia.dark_hemp_bud": "Tête de chanvre — Sombre",
"item.canaplia.dark_resin_block": "Bloc de résine — Sombre",
"item.canaplia.dark_rolled_joint": "Joint roulé — Sombre",
"variant.canaplia.ashen": "Cendrée",
"origin.canaplia.ashen": "Sauvage",
"hint.canaplia.ashen": "Cherchez les plantes des régions froides.",
"item.canaplia.ashen_hemp_seeds": "Graines de chanvre — Cendrée",
"item.canaplia.ashen_hemp_bud": "Tête de chanvre — Cendrée",
"item.canaplia.ashen_resin_block": "Bloc de résine — Cendrée",
"item.canaplia.ashen_rolled_joint": "Joint roulé — Cendrée",
"variant.canaplia.moss": "Mousse",
"origin.canaplia.moss": "Sauvage",
"hint.canaplia.moss": "Cherchez dans les régions humides ou luxuriantes.",
"item.canaplia.moss_hemp_seeds": "Graines de chanvre — Mousse",
"item.canaplia.moss_hemp_bud": "Tête de chanvre — Mousse",
"item.canaplia.moss_resin_block": "Bloc de résine — Mousse",
"item.canaplia.moss_rolled_joint": "Joint roulé — Mousse",
"variant.canaplia.gold": "Dorée",
"origin.canaplia.gold": "Hybride",
"hint.canaplia.gold": "Récoltez une Tendre près d'une Olive.",
"item.canaplia.gold_hemp_seeds": "Graines de chanvre — Dorée",
"item.canaplia.gold_hemp_bud": "Tête de chanvre — Dorée",
"item.canaplia.gold_resin_block": "Bloc de résine — Dorée",
"item.canaplia.gold_rolled_joint": "Joint roulé — Dorée",
"variant.canaplia.amber": "Ambrée",
"origin.canaplia.amber": "Sauvage",
"hint.canaplia.amber": "Cherchez la végétation des badlands ; cette forme est rare.",
"item.canaplia.amber_hemp_seeds": "Graines de chanvre — Ambrée",
"item.canaplia.amber_hemp_bud": "Tête de chanvre — Ambrée",
"item.canaplia.amber_resin_block": "Bloc de résine — Ambrée",
"item.canaplia.amber_rolled_joint": "Joint roulé — Ambrée",
"variant.canaplia.bluish": "Bleutée",
"origin.canaplia.bluish": "Hybride",
"hint.canaplia.bluish": "Récoltez une Cendrée près d'une Tendre dans le froid.",
"item.canaplia.bluish_hemp_seeds": "Graines de chanvre — Bleutée",
"item.canaplia.bluish_hemp_bud": "Tête de chanvre — Bleutée",
"item.canaplia.bluish_resin_block": "Bloc de résine — Bleutée",
"item.canaplia.bluish_rolled_joint": "Joint roulé — Bleutée",
"variant.canaplia.pink": "Rose",
"origin.canaplia.pink": "Hybride",
"hint.canaplia.pink": "Récoltez une Tendre près d'une Violette au crépuscule.",
"item.canaplia.pink_hemp_seeds": "Graines de chanvre — Rose",
"item.canaplia.pink_hemp_bud": "Tête de chanvre — Rose",
"item.canaplia.pink_resin_block": "Bloc de résine — Rose",
"item.canaplia.pink_rolled_joint": "Joint roulé — Rose",
"variant.canaplia.coral": "Corail",
"origin.canaplia.coral": "Hybride",
"hint.canaplia.coral": "Récoltez une Ambrée près d'une Mousse au bord de l'eau.",
"item.canaplia.coral_hemp_seeds": "Graines de chanvre — Corail",
"item.canaplia.coral_hemp_bud": "Tête de chanvre — Corail",
"item.canaplia.coral_resin_block": "Bloc de résine — Corail",
"item.canaplia.coral_rolled_joint": "Joint roulé — Corail",
"variant.canaplia.purple": "Violette",
"origin.canaplia.purple": "Hybride",
"hint.canaplia.purple": "Récoltez une Sombre près d'une Mousse dans une pénombre humide.",
"item.canaplia.purple_hemp_seeds": "Graines de chanvre — Violette",
"item.canaplia.purple_hemp_bud": "Tête de chanvre — Violette",
"item.canaplia.purple_resin_block": "Bloc de résine — Violette",
"item.canaplia.purple_rolled_joint": "Joint roulé — Violette",
"variant.canaplia.indigo": "Indigo",
"origin.canaplia.indigo": "End",
"hint.canaplia.indigo": "Cherchez les chorus dans l'End.",
"item.canaplia.indigo_hemp_seeds": "Graines de chanvre — Indigo",
"item.canaplia.indigo_hemp_bud": "Tête de chanvre — Indigo",
"item.canaplia.indigo_resin_block": "Bloc de résine — Indigo",
"item.canaplia.indigo_rolled_joint": "Joint roulé — Indigo",
"variant.canaplia.varieg": "Panachée",
"origin.canaplia.varieg": "Mutation",
"hint.canaplia.varieg": "Mélangez des formes mûres distinctes dans un milieu humide.",
"item.canaplia.varieg_hemp_seeds": "Graines de chanvre — Panachée",
"item.canaplia.varieg_hemp_bud": "Tête de chanvre — Panachée",
"item.canaplia.varieg_resin_block": "Bloc de résine — Panachée",
"item.canaplia.varieg_rolled_joint": "Joint roulé — Panachée",
"variant.canaplia.lunar": "Lunaire",
"origin.canaplia.lunar": "Lunaire",
"hint.canaplia.lunar": "Récoltez une Cendrée ou Bleutée mûre, de nuit, sous un ciel froid et dégagé.",
"item.canaplia.lunar_hemp_seeds": "Graines de chanvre — Lunaire",
"item.canaplia.lunar_hemp_bud": "Tête de chanvre — Lunaire",
"item.canaplia.lunar_resin_block": "Bloc de résine — Lunaire",
"item.canaplia.lunar_rolled_joint": "Joint roulé — Lunaire",
"variant.canaplia.ember": "Braise",
"origin.canaplia.ember": "Nether",
"hint.canaplia.ember": "Cherchez les racines, pousses et lianes natives du Nether.",
"item.canaplia.ember_hemp_seeds": "Graines de chanvre — Braise",
"item.canaplia.ember_hemp_bud": "Tête de chanvre — Braise",
"item.canaplia.ember_resin_block": "Bloc de résine — Braise",
"item.canaplia.ember_rolled_joint": "Joint roulé — Braise",
"tag.item.canaplia.hemp_buds": "Têtes de Canaplia"
}
@@ -0,0 +1,130 @@
{
"description.canaplia": "Полная механика Canaplia: шестнадцать растений, серверные открытия, гибриды и эффекты с визуальным слоем Ambiance.",
"screen.canaplia.codex.title": "Кодекс Canaplia",
"screen.canaplia.codex.page": "Страница %s / %s",
"screen.canaplia.codex.introduction": "КОДЕКС CANAPLIA\n\nУбивайте монстров, чтобы находить отдельные страницы и, очень редко, полный Кодекс.\n\nВыращивайте и открывайте сорта, чтобы заполнить его.",
"screen.canaplia.codex.entry_number": "Запись %s",
"screen.canaplia.codex.origin": "Происхождение: %s",
"screen.canaplia.codex.hidden": "Не открыто",
"screen.canaplia.codex.silhouette": "Известный силуэт",
"message.canaplia.discovered": "Открыта Canaplia: %s",
"message.canaplia.composter.mixed": "В этом компостере уже находится другой вариант Canaplia.",
"block.canaplia.hemp_crop": "Растение Canaplia",
"block.canaplia.hemp_top": "Верхушка Canaplia",
"block.canaplia.resin_block": "Блок смолы Canaplia",
"item.canaplia.codex": "Кодекс Canaplia",
"item.canaplia.codex_page": "Страница Canaplia",
"variant.canaplia.common": "Обычная",
"origin.canaplia.common": "Дикая",
"hint.canaplia.common": "Ищите среди обычной растительности Верхнего мира.",
"item.canaplia.hemp_seeds": "Обычная — семена конопли",
"item.canaplia.hemp_bud": "Обычная — шишка конопли",
"item.canaplia.resin_block": "Обычная — блок смолы",
"item.canaplia.rolled_joint": "Обычная — самокрутка",
"variant.canaplia.tender": "Нежная",
"origin.canaplia.tender": "Дикая",
"hint.canaplia.tender": "Ищите цветы на умеренных лугах.",
"item.canaplia.tender_hemp_seeds": "Нежная — семена конопли",
"item.canaplia.tender_hemp_bud": "Нежная — шишка конопли",
"item.canaplia.tender_resin_block": "Нежная — блок смолы",
"item.canaplia.tender_rolled_joint": "Нежная — самокрутка",
"variant.canaplia.olive": "Оливковая",
"origin.canaplia.olive": "Дикая",
"hint.canaplia.olive": "Ищите сухие растения в жарких местах.",
"item.canaplia.olive_hemp_seeds": "Оливковая — семена конопли",
"item.canaplia.olive_hemp_bud": "Оливковая — шишка конопли",
"item.canaplia.olive_resin_block": "Оливковая — блок смолы",
"item.canaplia.olive_rolled_joint": "Оливковая — самокрутка",
"variant.canaplia.dark": "Тёмная",
"origin.canaplia.dark": "Дикая",
"hint.canaplia.dark": "Ищите растения под тёмным лесным пологом.",
"item.canaplia.dark_hemp_seeds": "Тёмная — семена конопли",
"item.canaplia.dark_hemp_bud": "Тёмная — шишка конопли",
"item.canaplia.dark_resin_block": "Тёмная — блок смолы",
"item.canaplia.dark_rolled_joint": "Тёмная — самокрутка",
"variant.canaplia.ashen": "Пепельная",
"origin.canaplia.ashen": "Дикая",
"hint.canaplia.ashen": "Ищите растения в холодных регионах.",
"item.canaplia.ashen_hemp_seeds": "Пепельная — семена конопли",
"item.canaplia.ashen_hemp_bud": "Пепельная — шишка конопли",
"item.canaplia.ashen_resin_block": "Пепельная — блок смолы",
"item.canaplia.ashen_rolled_joint": "Пепельная — самокрутка",
"variant.canaplia.moss": "Моховая",
"origin.canaplia.moss": "Дикая",
"hint.canaplia.moss": "Ищите растения во влажных или пышных регионах.",
"item.canaplia.moss_hemp_seeds": "Моховая — семена конопли",
"item.canaplia.moss_hemp_bud": "Моховая — шишка конопли",
"item.canaplia.moss_resin_block": "Моховая — блок смолы",
"item.canaplia.moss_rolled_joint": "Моховая — самокрутка",
"variant.canaplia.gold": "Золотая",
"origin.canaplia.gold": "Гибрид",
"hint.canaplia.gold": "Соберите Нежную рядом с Оливковой.",
"item.canaplia.gold_hemp_seeds": "Золотая — семена конопли",
"item.canaplia.gold_hemp_bud": "Золотая — шишка конопли",
"item.canaplia.gold_resin_block": "Золотая — блок смолы",
"item.canaplia.gold_rolled_joint": "Золотая — самокрутка",
"variant.canaplia.amber": "Янтарная",
"origin.canaplia.amber": "Дикая",
"hint.canaplia.amber": "Ищите растительность бесплодных земель; эта форма редкая.",
"item.canaplia.amber_hemp_seeds": "Янтарная — семена конопли",
"item.canaplia.amber_hemp_bud": "Янтарная — шишка конопли",
"item.canaplia.amber_resin_block": "Янтарная — блок смолы",
"item.canaplia.amber_rolled_joint": "Янтарная — самокрутка",
"variant.canaplia.bluish": "Голубоватая",
"origin.canaplia.bluish": "Гибрид",
"hint.canaplia.bluish": "Соберите Пепельную рядом с Нежной в холоде.",
"item.canaplia.bluish_hemp_seeds": "Голубоватая — семена конопли",
"item.canaplia.bluish_hemp_bud": "Голубоватая — шишка конопли",
"item.canaplia.bluish_resin_block": "Голубоватая — блок смолы",
"item.canaplia.bluish_rolled_joint": "Голубоватая — самокрутка",
"variant.canaplia.pink": "Розовая",
"origin.canaplia.pink": "Гибрид",
"hint.canaplia.pink": "Соберите Нежную рядом с Фиолетовой в сумерках.",
"item.canaplia.pink_hemp_seeds": "Розовая — семена конопли",
"item.canaplia.pink_hemp_bud": "Розовая — шишка конопли",
"item.canaplia.pink_resin_block": "Розовая — блок смолы",
"item.canaplia.pink_rolled_joint": "Розовая — самокрутка",
"variant.canaplia.coral": "Коралловая",
"origin.canaplia.coral": "Гибрид",
"hint.canaplia.coral": "Соберите Янтарную рядом с Моховой у воды.",
"item.canaplia.coral_hemp_seeds": "Коралловая — семена конопли",
"item.canaplia.coral_hemp_bud": "Коралловая — шишка конопли",
"item.canaplia.coral_resin_block": "Коралловая — блок смолы",
"item.canaplia.coral_rolled_joint": "Коралловая — самокрутка",
"variant.canaplia.purple": "Фиолетовая",
"origin.canaplia.purple": "Гибрид",
"hint.canaplia.purple": "Соберите Тёмную рядом с Моховой при слабом свете и высокой влажности.",
"item.canaplia.purple_hemp_seeds": "Фиолетовая — семена конопли",
"item.canaplia.purple_hemp_bud": "Фиолетовая — шишка конопли",
"item.canaplia.purple_resin_block": "Фиолетовая — блок смолы",
"item.canaplia.purple_rolled_joint": "Фиолетовая — самокрутка",
"variant.canaplia.indigo": "Индиго",
"origin.canaplia.indigo": "Энд",
"hint.canaplia.indigo": "Ищите хорусы в Энде.",
"item.canaplia.indigo_hemp_seeds": "Индиго — семена конопли",
"item.canaplia.indigo_hemp_bud": "Индиго — шишка конопли",
"item.canaplia.indigo_resin_block": "Индиго — блок смолы",
"item.canaplia.indigo_rolled_joint": "Индиго — самокрутка",
"variant.canaplia.varieg": "Пёстрая",
"origin.canaplia.varieg": "Мутация",
"hint.canaplia.varieg": "Смешайте разные зрелые формы во влажной среде.",
"item.canaplia.varieg_hemp_seeds": "Пёстрая — семена конопли",
"item.canaplia.varieg_hemp_bud": "Пёстрая — шишка конопли",
"item.canaplia.varieg_resin_block": "Пёстрая — блок смолы",
"item.canaplia.varieg_rolled_joint": "Пёстрая — самокрутка",
"variant.canaplia.lunar": "Лунная",
"origin.canaplia.lunar": "Лунная",
"hint.canaplia.lunar": "Соберите зрелую Пепельную или Голубоватую ночью под открытым холодным небом.",
"item.canaplia.lunar_hemp_seeds": "Лунная — семена конопли",
"item.canaplia.lunar_hemp_bud": "Лунная — шишка конопли",
"item.canaplia.lunar_resin_block": "Лунная — блок смолы",
"item.canaplia.lunar_rolled_joint": "Лунная — самокрутка",
"variant.canaplia.ember": "Угольная",
"origin.canaplia.ember": "Незер",
"hint.canaplia.ember": "Ищите местные корни, ростки и лозы в Незере.",
"item.canaplia.ember_hemp_seeds": "Угольная — семена конопли",
"item.canaplia.ember_hemp_bud": "Угольная — шишка конопли",
"item.canaplia.ember_resin_block": "Угольная — блок смолы",
"item.canaplia.ember_rolled_joint": "Угольная — самокрутка",
"tag.item.canaplia.hemp_buds": "Шишки Canaplia"
}
@@ -0,0 +1,9 @@
# Canaplia texture provenance
The 224 pixel-art sprites in `block/` and `item/` are the authored Canaplia
textures restored from the copyright holder's local Sanctuary source tree at
commit `b14fbe561ac99054f17ae56caee9eebee3dbcbb6`.
They are included in Sanctuary 26.2 at the copyright holder's explicit request.
Minecraft book, paper, and book-screen assets remain referenced from the
`minecraft` namespace and are not copied here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

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