Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39c271c96 | ||
|
|
a16156b005 | ||
|
|
2518c20071 | ||
|
|
b7497c81d1 | ||
|
|
97638e0fa2 | ||
|
|
a071a0b746 | ||
|
|
6fcc56552c | ||
|
|
66944ec492 | ||
|
|
b513ab1262 | ||
|
|
b2222c5d33 | ||
|
|
2d0be21c5b | ||
|
|
a0b0788869 | ||
|
|
106d730dd8 | ||
|
|
21dd089486 | ||
|
|
e3b6c6ed01 | ||
|
|
7387096d98 | ||
|
|
457f91b92e | ||
|
|
f283c1705b | ||
|
|
6ee5cdc2fb | ||
|
|
87428c5f0d | ||
|
|
0599bf6885 | ||
|
|
624f441578 | ||
|
|
d6ca1140b5 | ||
|
|
53fa32bbc5 | ||
|
|
7a2f697024 | ||
|
|
4d0750e993 | ||
|
|
c49673801f | ||
|
|
5386c7ac8a | ||
|
|
eb25625a45 | ||
|
|
05c445eaeb | ||
|
|
788928f2d9 | ||
|
|
5d3db9bb42 | ||
|
|
fd528abb9d | ||
|
|
40acccc818 | ||
|
|
62c8c87eb0 | ||
|
|
b5dab3b5f6 | ||
|
|
90790161db | ||
|
|
e8dee9b7f8 | ||
|
|
6fc93ca732 | ||
|
|
8787969347 | ||
|
|
01a82c3c93 | ||
|
|
f23f741a9d | ||
|
|
4714c69255 | ||
|
|
c03e5d8555 | ||
|
|
9abf6bbea7 | ||
|
|
9c09664778 | ||
|
|
87f11f6127 | ||
|
|
c04cd78ae9 | ||
|
|
7ae99a12a8 | ||
|
|
1b1f3a791d | ||
|
|
10b1cd9c5e | ||
|
|
ef18e0ae32 | ||
|
|
1c2e000da3 | ||
|
|
9e35c244ec | ||
|
|
e82cb33407 | ||
|
|
6e58d44c71 | ||
|
|
d45afc7767 | ||
|
|
e126ec9eea | ||
|
|
13a74ddbd2 | ||
|
|
b221ca1b52 | ||
|
|
05505279f8 | ||
|
|
61e66d3a11 | ||
|
|
3aa36ed129 | ||
|
|
90b9eee27e | ||
|
|
7d800d8932 | ||
|
|
913a754f20 | ||
|
|
73d32999e5 | ||
|
|
86034ca71f | ||
|
|
db91698c09 | ||
|
|
7f74114d31 | ||
|
|
53db634ec2 | ||
|
|
400f1f6289 | ||
|
|
596432f3f7 | ||
|
|
14980c993f | ||
|
|
e17433f28f | ||
|
|
0833d4e44f | ||
|
|
07b2eba5c5 | ||
|
|
0f95a5cfc7 | ||
|
|
028d63b64a | ||
|
|
61ba842206 | ||
|
|
0476a74d43 | ||
|
|
d5fe55ddcc | ||
|
|
4a9f50e488 | ||
|
|
b9aabae914 | ||
|
|
b121606b4e | ||
|
|
fa40548db7 | ||
|
|
7ee1e5f094 | ||
|
|
91b4def490 | ||
|
|
122ff939aa | ||
|
|
3fef2d5437 | ||
|
|
75ddc5b682 | ||
|
|
bf09bdfa78 | ||
|
|
2df4f35e48 | ||
|
|
4b4881d922 | ||
|
|
66cd185983 | ||
|
|
bd93dc1cd6 | ||
|
|
2838bf0767 | ||
|
|
e6740ff891 | ||
|
|
054c8e4ad4 | ||
|
|
4b11d217c2 | ||
|
|
3923b8547c | ||
|
|
76915e2f44 | ||
|
|
5aaaf3831c | ||
|
|
37beda56ec | ||
|
|
5d48917cf4 | ||
|
|
18cca8808b | ||
|
|
cbba8dc2d3 | ||
|
|
59239b1721 | ||
|
|
680b6d647a | ||
|
|
dd993d7064 | ||
|
|
1ce299f88a | ||
|
|
b708bf5ffa | ||
|
|
2e1585f12f | ||
|
|
1a8a983506 | ||
|
|
7db698881e | ||
|
|
2d286ef54c | ||
|
|
e599727d26 | ||
|
|
236d28854e | ||
|
|
1377678e30 | ||
|
|
91525a211d | ||
|
|
702a1d70c3 | ||
|
|
ee5bc3d109 | ||
|
|
cbf09dfb0e | ||
|
|
4fc59802d3 | ||
|
|
64a5eca235 | ||
|
|
b5a30d294d | ||
|
|
394b163e6b | ||
|
|
fd3c8bc8d7 | ||
|
|
b215128e08 | ||
|
|
fe491c0ebd | ||
|
|
64b54c9b44 | ||
|
|
6b01d8f2f1 | ||
|
|
fd9fd1435b | ||
|
|
1b9ee0aac0 | ||
|
|
b0838ff9fd | ||
|
|
95ad129656 | ||
|
|
51c1ad0e11 | ||
|
|
dd305cd64e | ||
|
|
c9a8d601a8 | ||
|
|
9e302e12ef | ||
|
|
414afb15da | ||
|
|
0e1037168d | ||
|
|
e7359ac917 | ||
|
|
b8cf6bcf41 | ||
|
|
747162e2ae | ||
|
|
a802295c08 | ||
|
|
4fc06ec0e2 | ||
|
|
3eed6b5a65 | ||
|
|
452347e672 | ||
|
|
3deeb90ffc | ||
|
|
1a0294cbbc | ||
|
|
11a916a4ce | ||
|
|
e2b60e7e3b | ||
|
|
a9eeb124b1 | ||
|
|
a836099686 | ||
|
|
4f0fa4da65 | ||
|
|
f245863dec | ||
|
|
79ac34dd1c | ||
|
|
9f3e1ba82f | ||
|
|
4f0d98a760 | ||
|
|
fb88f57fd7 | ||
|
|
4010e11f24 |
@@ -0,0 +1,60 @@
|
||||
# Migration de Canaplia
|
||||
|
||||
Canaplia est désormais implémenté dans Sanctuary 26.2 par les tickets [CANA-01 à CANA-09](https://git.botsu.net/koka/sanctuary/issues?state=open). Il reste un namespace de compatibilité réparti entre les propriétaires fonctionnels existants, et non un onzième sous-projet Gradle.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Responsabilité | Propriétaire | Contrat public |
|
||||
|---|---|---|
|
||||
| cultures, variantes, récolte, compostage, résine, effets serveur et Codex | It's Alive | classes itsalive.canaplia, registres historiques canaplia:* |
|
||||
| calendrier, météo, rendu visuel et accessibilité | Ambiance | AmbianceLunarHarvestApi, AmbianceVisualEffectApi et payloads bornés |
|
||||
| événements de première découverte | Sanctuary | SanctuaryProgressionService.recordExternal |
|
||||
| climat et biomes | Another World / It's Alive | ClimateCropForaging |
|
||||
| exclusions Shop et récompenses aléatoires | Only Fun | OnlyFunItemPolicy |
|
||||
|
||||
It's Alive fournit l'alias Fabric canaplia et dépend explicitement d'Another World, d'Ambiance et de Sanctuary. Ambiance dépend de Sanctuary mais jamais d'It's Alive ; aucun cycle n'est introduit.
|
||||
|
||||
## Contenu et survie
|
||||
|
||||
Les seize clés stables sont common, tender, olive, dark, ashen, moss, gold, amber, bluish, pink, coral, purple, indigo, varieg, lunar et ember.
|
||||
|
||||
Les trois blocs historiques, le type de block entity et les 64 items historiques sont enregistrés une seule fois. Les cultures partagent une croissance à huit âges et conservent leur variante dans le blockstate. Dix têtes identiques dans un composteur produisent le bloc de résine correspondant ; une autre variante est refusée. La résine est posée éteinte, s'allume au briquet, à la charge de feu ou avec un projectile enflammé, émet une fumée visible et applique son aura serveur pendant 90 secondes avant de disparaître.
|
||||
|
||||
Les sources sauvages sont chargées depuis data/canaplia/canaplia_discovery/sources.json. Elles fonctionnent lors de la casse de flore dans les chunks existants. Ember est limitée à la flore native du Nether, Indigo aux chorus de l'End, et toutes les variantes Canaplia sont exclues du Shop et des récompenses ordinaires.
|
||||
|
||||
Les hybridations utilisent uniquement des parents matures dans un rayon de un bloc. Le serveur revalide climat, humidité, lumière, eau, horaire, nuit et ciel visible au moment de la récolte et n'effectue jamais plus d'un tirage. La règle Lunar passe par l'instantané public d'Ambiance.
|
||||
|
||||
## Effets, rendu et accessibilité
|
||||
|
||||
La consommation démarre une session autoritaire serveur de 140 à 180 secondes. Un bad trip éventuel est tiré une seule fois, commence après un délai borné et dure de 25 à 40 secondes. Les rafraîchissements restent bornés ; mort, expiration et déconnexion nettoient la session. L'aura d'une résine ne relance jamais le tirage.
|
||||
|
||||
Ambiance possède les trois payloads historiques canaplia:hemp_visual_sync, canaplia:hemp_bad_trip et canaplia:hemp_visual_clear. Il compose un profil normal et un profil de bad trip avec sa météo, sa lightmap et son contrat Iris/Canon, sans shader privé concurrent. Les réglages locaux d'intensité, de réduction des mouvements, de wobble et de ghosting sont disponibles dans les contrôles via « Réglages d'accessibilité visuelle » et n'altèrent jamais le gameplay serveur.
|
||||
|
||||
## Codex
|
||||
|
||||
Les monstres tués par un joueur peuvent laisser une `canaplia:codex_page` et, très rarement, le `canaplia:codex` complet. Quatre pages récupérées permettent aussi d'assembler le Livre interdit autour d'un livre vanilla. Un clic droit sur une page découvre au hasard une entrée encore inconnue et ne consomme la page qu'en cas de nouvelle découverte.
|
||||
|
||||
L'ID historique `canaplia:codex` est conservé, mais l'objet s'affiche désormais comme le « Livre interdit » : il utilise la couleur du livre enchanté, ouvre directement les seize entrées et les présente sur les pages claires du livre vanilla. En main, son contenu est strictement personnel. Posé sur un pupitre, il est temporairement inscrit au nom du joueur qui le pose et partage ses découvertes avec tous les lecteurs ; le récupérer en s'accroupissant ou casser le pupitre efface cette inscription.
|
||||
|
||||
Les données `canaplia:codex` restent en version 1. Les clés futures syntaxiquement valides et l'ancien champ partagé restent conservés lors d'un aller-retour afin qu'une migration ne perde aucune découverte, mais les nouvelles découvertes ne sont plus publiées globalement. Aucun payload de découverte n'est accepté du client vers le serveur et le protocole It's Alive reste en version 4.
|
||||
|
||||
## Tickets livrés
|
||||
|
||||
| Ticket | Fonction |
|
||||
|---|---|
|
||||
| [#36 CANA-02](https://git.botsu.net/koka/sanctuary/issues/36) | seize familles, ressources et compostage |
|
||||
| [#37 CANA-03](https://git.botsu.net/koka/sanctuary/issues/37) | découvertes climatiques data-driven |
|
||||
| [#38 CANA-04](https://git.botsu.net/koka/sanctuary/issues/38) | hybridations serveur |
|
||||
| [#39 CANA-05](https://git.botsu.net/koka/sanctuary/issues/39) | Ember Nether et Indigo End |
|
||||
| [#40 CANA-06](https://git.botsu.net/koka/sanctuary/issues/40) | récolte Lunar liée à Ambiance |
|
||||
| [#41 CANA-07](https://git.botsu.net/koka/sanctuary/issues/41) | effets serveur et bad trips |
|
||||
| [#42 CANA-08](https://git.botsu.net/koka/sanctuary/issues/42) | composition visuelle accessible |
|
||||
| [#43 CANA-09](https://git.botsu.net/koka/sanctuary/issues/43) | Codex persistant personnel et partagé |
|
||||
|
||||
## Compatibilité, licence et migrations
|
||||
|
||||
Les IDs historiques, les clés et le namespace sont inchangés. L'ancien format non versionné de canaplia:hemp_bud_composters reste lisible et est réécrit sous une enveloppe v1. Le block entity de résine conserve `remaining_burn_ticks` et migre sa donnée v1 déjà active vers l'état allumé v2. Le Codex utilise sa propre donnée v1. Les protocoles restent It's Alive v4 et Ambiance v5.
|
||||
|
||||
La source historique 2.0.0 demeure **All Rights Reserved**, copyright KOKA99CAB. À la demande explicite du détenteur des droits, ses 224 textures 16×16 ont été restaurées dans l'alpha.209 ; leur provenance est documentée avec les ressources. Aucun fichier Java, modèle, son ou JSON historique n'est copié. Le code d'intégration reste sous GPL-3.0-or-later.
|
||||
|
||||
Les inventaires machine sont dans `pack/migrations/canaplia-foundation-alpha32.json` et `pack/migrations/canaplia-gameplay-alpha33.json`. La release `26.2.0-alpha.209` décrit la correction de gameplay et l'import autorisé dans `pack/migrations/26.2.0-alpha.208-canaplia-fixes-to-alpha.209.json`; l’alpha.210 décrit le Livre interdit et les autres changements distribués dans `pack/migrations/26.2.0-alpha.209-forbidden-book-and-polish-to-alpha.210.json`.
|
||||
@@ -6,6 +6,365 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
|
||||
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
|
||||
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
|
||||
|
||||
## Non publié
|
||||
|
||||
Modules modifiés : **Sanctuary `0.0.0-alpha.131`** et **Another World `0.0.0-alpha.57`**.
|
||||
|
||||
- ajoute la dimension persistante `sanctuary:backroom`, accessible en s’accroupissant devant un lit et quittée uniquement par un lit, avec arrivées variées dans une zone architecturale de 2 000 blocs ;
|
||||
- transfère atomiquement vers la Mailbox tout l’équipement d’entrée, y compris les rangées étendues, l’œuf compagnon et la cape, avec reprise idempotente après interruption ;
|
||||
- conserve les objets perdus au dépop, dans le vide et lors des morts en Backroom dans un registre borné et non duplicable ;
|
||||
- transforme tous les coffres de la dimension en butin renouvelable, pauvre la plupart du temps, qui privilégie les pertes personnelles dans les premiers coffres avant les pertes anciennes partagées ;
|
||||
- génère des villes, bureaux, plaines, agrégats flottants et pièces visuellement retournées à partir de palettes composées, en réservant les matériaux d’Another World aux anomalies et les blocs précieux à de rares cachettes ;
|
||||
- fait enregistrer la mousse turquoise par Another World via l’API publique de matériaux Backroom, sans dépendance circulaire ;
|
||||
- migre Sanctuary vers les données v14 sans changer le protocole réseau 24, sans réinitialiser les sauvegardes ni effacer les constructions.
|
||||
|
||||
## `26.2.0-alpha.226`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225.
|
||||
|
||||
- ajoute `sanctuary:white_cape`, une cape blanche craftable avec six laines blanches disposées comme une porte, en deux colonnes de trois ;
|
||||
- permet d'associer simplement la cape et n'importe quelle bannière dans l'établi afin d'en conserver la couleur de fond et jusqu'à six motifs ;
|
||||
- affiche les motifs dans l'inventaire et sur la cape équipée, avec la physique native des capes, y compris en multijoueur ;
|
||||
- conserve les sauvegardes Sanctuary en data v13 ; le payload d'apparence transporte désormais la couleur et les motifs bornés, donc le protocole Sanctuary passe de 23 à 24 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.225`
|
||||
|
||||
Modules modifiés : **Another World `0.0.0-alpha.56`**, **It's Alive `0.0.0-alpha.38`**, **I Like To Move It `0.0.0-alpha.18`**, **Only Fun `0.0.0-alpha.27`** et **Sanctuary `0.0.0-alpha.129`**. Les cinq autres modules conservent leur version issue de l'alpha.224.
|
||||
|
||||
- exige que l’attaquant possède son propre coffre-fort actif avant de commencer un braquage, revalide cette condition pendant le forage et refuse défensivement tout transfert si le coffre disparaît entre-temps ;
|
||||
- empêche les familiers temporaires Sanctuary de produire du butin vanilla, de l’expérience ou une lootbox Only Fun, et les exclut des contrats de panneau comme des demandes communautaires ;
|
||||
- conserve la variante Canaplia des seize graines historiques pendant le placement, la croissance naturelle, la poudre d’os, la création de la partie haute, le rechargement et la récolte, ce qui réactive notamment l’hybridation Tendre + Olive vers Dorée ;
|
||||
- remplace la laine violette du Magic Carpet par la texture historique 16 × 16 du Totem de Notch, utilisée par l’objet canonique et son alias hérité sans modifier la plateforme 3 × 3 ;
|
||||
- conserve tous les IDs, sauvegardes et formats de données ; les protocoles réseau restent Another World 2, It's Alive 4, I Like To Move It 5, Only Fun 3 et Sanctuary 23, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.224`
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.128`** et **MasterKey `0.0.0-alpha.9`**.
|
||||
|
||||
- étend le clic molette aux coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, tout en excluant les fours, le Shop et le terminal Red-Stoner ;
|
||||
- trie l'inventaire du joueur lorsque le curseur survole l'une de ses cases dans un conteneur, sans toucher à la hotbar, et replace la hotbar en bas après connexion ou achat d'une rangée ;
|
||||
- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ;
|
||||
- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ;
|
||||
- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ;
|
||||
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve une récompense physique unique ;
|
||||
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
|
||||
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
|
||||
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
|
||||
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
|
||||
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
|
||||
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13 ; le payload de tri ayant gagné un booléen borné, le protocole Sanctuary passe de 22 à 23 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.223`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.125`**. Les neuf autres modules conservent leur version issue de l'alpha.222.
|
||||
|
||||
- ajoute un tri natif de l’inventaire par clic molette configurable, uniquement sur les rangées de stockage débloquées ;
|
||||
- fusionne et range les piles depuis le haut sans déplacer la hotbar, l’armure, la main secondaire, le craft ni les emplacements spéciaux ;
|
||||
- garde le tri sous autorité serveur, avec un délai de huit ticks, et l’ignore en créatif ou pendant la saisie dans un champ texte ;
|
||||
- ajoute un style configurable au suivi des quêtes (`N` par défaut), entre le texte transparent et le panneau original de Chris ;
|
||||
- place automatiquement le style texte sous la hauteur réelle de la colonne gauche du F3 permanent afin que les deux affichages ne se recouvrent pas ;
|
||||
- nomme et ordonne les contrats suivis par niveau de récompense, rubis puis saphir puis émeraude/autres, au lieu du libellé générique « Contrat du jour » ;
|
||||
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.198` et synchronise ses empreintes entre Packwiz et Prism ;
|
||||
- conserve les données Sanctuary v11, le protocole réseau 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n’est nécessaire.
|
||||
|
||||
## `26.2.0-alpha.222`
|
||||
|
||||
Modules modifiés : **Red-Stoner `0.0.0-alpha.13`** et **Sanctuary `0.0.0-alpha.124`**. Les huit autres modules conservent leur version issue de l'alpha.221.
|
||||
|
||||
- ajoute au terminal de stockage une recherche clavier focalisée à l'ouverture, compatible avec les noms affichés, les IDs, les traductions, les accents et Unicode ;
|
||||
- affiche les grandes quantités du terminal sous forme compacte (`1K`, `1.2K`, `12K`, `1.2M`) tout en conservant la valeur exacte dans l'infobulle ;
|
||||
- stabilise les emplacements du terminal lorsqu'une pile est vidée afin que les ressources suivantes ne glissent plus sous le curseur, puis recompacte volontairement lors d'une nouvelle recherche ou à la réouverture ;
|
||||
- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ;
|
||||
- affiche chaque quête active comme un simple texte ombré en haut à gauche, sans panneau, bordure, titre, message vide ni barre de progression ;
|
||||
- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ;
|
||||
- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches.
|
||||
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.196` et synchronise Packwiz avec l'index Prism/Modrinth ;
|
||||
- conserve Red-Stoner data v3/protocole 7, Sanctuary data v11/protocole 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
|
||||
|
||||
## `26.2.0-alpha.221`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220.
|
||||
|
||||
- corrige la cause réelle du disque blanc ralenti et trop grave : la piste AAC YouTube reste décodée en stéréo native à 48 kHz, puis chaque paire gauche/droite est explicitement moyennée en un échantillon mono positionnel ;
|
||||
- supprime la compensation OpenAL approximative à `4/3` et restitue la musique à sa hauteur et sa vitesse originales avec une hauteur `1.0` ;
|
||||
- conserve un flux PCM mono signé 16 bits little-endian pour l'audio positionnel du jukebox, sans silence ajouté ni changement de durée ;
|
||||
- ajoute un test déterministe du downmix stéréo et valide le lien signalé sur quatre secondes : 4 000 ms de source donnent 4 000 ms de PCM à 48 kHz ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
|
||||
|
||||
## `26.2.0-alpha.220`
|
||||
|
||||
Module modifié : **Red-Stoner `0.0.0-alpha.12`**. Les neuf autres modules conservent leur version issue de l'alpha.219.
|
||||
|
||||
- remplace l’écran propriétaire du terminal par un véritable menu de conteneur inspiré des coffres vanilla et composé avec le panneau d’inventaire Sanctuary ;
|
||||
- supprime le bouton de fermeture dédié : Échap et la touche d’inventaire suivent désormais le comportement normal du jeu ;
|
||||
- affiche et synchronise toutes les rangées d’inventaire débloquées par Sanctuary, d’une à six, y compris les deux rangées persistantes supplémentaires ;
|
||||
- permet le retrait par clic gauche, le retrait unitaire par clic droit et le retrait vers l’inventaire par shift-clic ; un clic avec une pile portée ou un shift-clic depuis l’inventaire dépose dans le réseau ;
|
||||
- conserve les 54 cases comme une vue agrégée non réorganisable et affiche les quantités exactes sans créer de stockage virtuel ;
|
||||
- dépose dans le premier coffre ou baril disponible selon l’ordre déterministe de découverte, remplit d’abord ses piles compatibles puis ses cases vides et ne passe au stockage suivant qu’en cas de besoin ;
|
||||
- supprime les anciens payloads spécifiques au terminal au profit de la synchronisation native des menus, tout en gardant la distance, la topologie et les transferts sous autorité serveur ;
|
||||
- conserve les données Red-Stoner v3 et les sauvegardes existantes ; le protocole réseau Red-Stoner passe de 6 à 7, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.219`
|
||||
|
||||
Modules modifiés : **Sanctuary `0.0.0-alpha.122`**, **I Like To Move It `0.0.0-alpha.16`** et **Ambiance `0.0.0-alpha.21`**. Les sept autres modules conservent leur version issue de l'alpha.218.
|
||||
|
||||
- ajoute `sanctuary:alpha`, une île Minecraft Indev finie de 128 × 128 × 64 blocs, déterministe par seed, avec plages, cavernes, minerais, eau, lave, végétation et maison de départ historique ;
|
||||
- borne le monde par un fond en bedrock et un anneau de barrières, sans placement au-delà de X/Z −64…63, et expose les positions stables de la résidence et de l'arrivée sûre via l'API publique de Sanctuary ;
|
||||
- ajoute la dimension, son biome, son type et ses réglages aux profils Sanctuary et Overworld, tout en conservant la migration `portals:alpha` vers `sanctuary:alpha` ;
|
||||
- active la route de portail v2 en cobblestone moussue, cadre 4 × 5 et intérieur 2 × 3, avec validation serveur, refus si la dimension manque, paire persistante, déliaison à la casse et arrivée distante hors de la maison ;
|
||||
- compose localement les packs officiels Golden Days Base puis Alpha uniquement dans `sanctuary:alpha`, restaure exactement la pile précédente à la sortie ou déconnexion et laisse les textures vanilla fonctionner si les ZIP manquent ;
|
||||
- ne distribue aucun asset Golden Days dans les JAR, Packwiz ou `.mrpack`, documente les liens et la licence officiels, et n'impose aucun fichier au serveur dédié ;
|
||||
- ajoute les traductions FR/EN/RU et des tests déterministes pour le générateur, le portail et la composition des packs ;
|
||||
- conserve Sanctuary data v11/protocole 22, I Like To Move It data v3/protocole 5 et Ambiance data v2/protocole 6, sans réinitialisation de sauvegarde.
|
||||
|
||||
## `26.2.0-alpha.218`
|
||||
|
||||
Modules modifiés : **Red-Stoner `0.0.0-alpha.11`**, **Only Fun `0.0.0-alpha.25`** et **Sanctuary `0.0.0-alpha.121`**. Les sept autres modules conservent leur version issue de l'alpha.217.
|
||||
|
||||
- ajoute `redstoner:storage_terminal`, une demi-dalle verticale de huit pixels qui se pose uniquement sur la face horizontale d’un coffre, coffre piégé ou baril ;
|
||||
- découvre sans charger de chunk les conteneurs adjacents par face, verticalement compris, et refuse clairement tout réseau qui dépasse 128 blocs physiques ;
|
||||
- agrège uniquement les piles dont l’item et tous les composants sont strictement identiques, puis les affiche sur des pages défilantes de 54 entrées ;
|
||||
- valide côté serveur la distance, le terminal, la topologie, la page et l’identité de chaque pile avant un retrait par pile, un retrait unitaire ou un dépôt par shift-clic ;
|
||||
- fabrique le terminal avec quatre plaques d’argent, trois verres teintés, un Controller et un lingot de titane, sans entrée Shop, Black Market ni récompense de lootbox ;
|
||||
- ajoute les traductions FR/EN/RU, les ressources bloc/item/loot/recette et des tests déterministes de topologie, pagination, agrégation et transferts ;
|
||||
- conserve les données Red-Stoner v3, Only Fun v2 et Sanctuary v11 ; seul le protocole réseau Red-Stoner passe de 5 à 6, sans nouveau monde ni réinitialisation des sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.217`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.20`**. Les neuf autres modules conservent leur version issue de l'alpha.216.
|
||||
|
||||
- corrige la hauteur trop grave et la vitesse trop lente du disque blanc en compensant la cadence du flux AAC YouTube par un facteur OpenAL exact de `4/3` ;
|
||||
- réapplique cette hauteur pendant toute la lecture afin qu’aucune mise à jour du canal audio ne la ramène à la valeur ralentie ;
|
||||
- valide la correction sur le lien signalé en comparant vingt secondes décodées par Lavaplayer au flux AAC direct, puis conserve les tests PCM 48 kHz et de niveau sonore ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.20` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.216`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.19`**. Les neuf autres modules conservent leur version issue de l'alpha.215.
|
||||
|
||||
- corrige le disque blanc ralenti et dégradé en remplissant continûment les tampons PCM d’une seconde demandés par Minecraft au lieu de les compléter prématurément avec du silence ;
|
||||
- conserve une fréquence réelle de 48 kHz, un flux mono 16 bits little-endian et force la vitesse OpenAL normale à `1.0` ;
|
||||
- borne chaque attente de trame à 250 ms après le démarrage afin de garder un flux continu sans bloquer indéfiniment le thread audio ;
|
||||
- ajoute un test déterministe des tampons Minecraft et renforce le test YouTube réel sur quatre secondes complètes ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.19` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.215`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.18`**. Les neuf autres modules conservent leur version issue de l'alpha.214.
|
||||
|
||||
- corrige le crash natif déclenché par l’insertion ou l’arrêt du disque blanc en laissant Minecraft libérer le canal OpenAL sur son thread audio ;
|
||||
- rétablit le décodage audible du lien YouTube signalé grâce au profil iOS corrigé et à une révision épinglée de `youtube-source` ;
|
||||
- borne uniquement l’attente initiale du flux puis poursuit la lecture sans blocage, avec silence de compensation lorsque les données arrivent en retard ;
|
||||
- transforme les erreurs et blocages de lecture en arrêt propre accompagné d’un message au joueur ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.18` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.214`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.17`**. Les neuf autres modules conservent leur version issue de l'alpha.213.
|
||||
|
||||
- attend de façon bornée la synchronisation client du jukebox avant de charger un disque blanc, ce qui rétablit sa lecture en solo ;
|
||||
- affiche immédiatement le chargement puis un échec explicite si le jukebox n'est toujours pas synchronisé après cinq secondes ;
|
||||
- accepte toujours les liens de partage YouTube avec paramètres, notamment `https://youtu.be/Ue5ZBe-GzSM?si=…` ;
|
||||
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
|
||||
- distribue Ambiance `0.0.0-alpha.17` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.213`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.16`**. Les neuf autres modules conservent leur version issue de l'alpha.212.
|
||||
|
||||
- corrige le crash client au démarrage d'Ambiance sous Minecraft 26.2 en ciblant le nouveau gestionnaire vanilla `LevelEventHandler` pour l'arrêt du disque blanc ;
|
||||
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
|
||||
- remplace le JAR Ambiance cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.212`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.120`**. Les neuf autres modules conservent leur version issue de l'alpha.211.
|
||||
|
||||
- corrige le crash client au démarrage provoqué par le mixin du clic molette du Black Market sous Minecraft 26.2 ;
|
||||
- cible désormais la méthode vanilla `pickBlockOrEntity`, sans modifier les données Sanctuary v11 ni le protocole réseau 22.
|
||||
- remplace le JAR Sanctuary cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.211`
|
||||
|
||||
Modules modifiés : **Ambiance `0.0.0-alpha.15`**, **It's Alive `0.0.0-alpha.36`**, **Only Fun `0.0.0-alpha.24`**, **Ouch `0.0.0-alpha.10`** et **Sanctuary `0.0.0-alpha.119`**. Les cinq autres modules conservent leur version issue de l’alpha.210.
|
||||
|
||||
- remplace les pages noires du Livre interdit par l’affichage clair du livre vanilla ;
|
||||
- conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4.
|
||||
- rétablit l'accès au Black Market par clic molette sur un panneau, avec une intention vide et une nouvelle validation complète de la cible côté serveur ;
|
||||
- conserve les données Sanctuary v11 et porte son protocole réseau de 21 à 22, sans nouvel ID persistant.
|
||||
- ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de l’insérer dans un jukebox ;
|
||||
- valide strictement l’hôte et l’identifiant vidéo côté serveur, ne transmet qu’un identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ;
|
||||
- diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6.
|
||||
- importe le cœur GPL historique d’Abjection dans Only Fun, sans onzième JAR : `/piss`, `/vomit`, `/shit` et `/smoke` retrouvent leurs durées, jets, impacts et messages ;
|
||||
- restaure `abjection:shit_ball` ainsi que le bloc boueux `abjection:shit_cube`, sa recette, son butin et ses ressources originales en FR/EN/RU ;
|
||||
- expose une API serveur unique pour les commandes et le futur raccourci, nettoie les actions temporaires à la mort, en spectateur ou à la déconnexion, et conserve les données v2 ainsi que le protocole réseau 2 d’Only Fun.
|
||||
- rend `abjection:shit_ball` lançable à la main et par dispenser grâce à une fabrique publique installée par Ouch, sans réenregistrer l’objet possédé par Only Fun ;
|
||||
- restaure le projectile historique de 0,25 bloc, sa consommation unique hors créatif, son rendu d’objet, son impact boueux et ses sept secondes de nausée ajoutées à la durée existante ;
|
||||
- déclare la dépendance sans cycle `ouch -> onlyfun` ; les données et protocoles réseau d’Ouch et d’Only Fun restent inchangés.
|
||||
- ajoute la touche configurable « Chier » dans les contrôles Only Fun, assignée à `J` par défaut et sans répétition lorsque la touche reste maintenue ;
|
||||
- transmet une demande C2S vide, déduplique le spam d’un même tick et revalide côté serveur la connexion, la vie et le mode spectateur avant de réutiliser exactement l’action `/shit` ;
|
||||
- conserve les données Only Fun v2 et porte son protocole réseau de 2 à 3, sans nouveau cooldown, besoin ni donnée persistante.
|
||||
- distribue les cinq JAR mis à jour dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.210`
|
||||
|
||||
Modules modifiés : **Sanctuary `0.0.0-alpha.118`**, **Only Fun `0.0.0-alpha.21`**, **I Like To Move It `0.0.0-alpha.15`** et **It's Alive `0.0.0-alpha.35`**. Les six autres modules conservent leur version issue de l’alpha.209.
|
||||
|
||||
- sépare le compteur quotidien et le statut de déblocage du Shop pour empêcher leur chevauchement ;
|
||||
- retire du Shop Only Fun les raccourcis qui contournaient la progression normale ;
|
||||
- attribue à tous les Zombies vanilla des gamertags déterministes inspirés de la Xbox 360 ;
|
||||
- resynchronise les capes après une reconnexion avec des tentatives retardées et bornées ;
|
||||
- réserve les requêtes communautaires aux panneaux dédiés au lieu d’intercepter les panneaux ordinaires ;
|
||||
- double la vitesse de croisière du Happy Ghast lorsqu’un joueur le pilote, sans accélérer ses déplacements autonomes ;
|
||||
- conserve les données v3 et le protocole réseau 5, sans migration de sauvegarde ni nouvel ID.
|
||||
- transforme le Codex Canaplia en « Livre interdit » aux pages noires, sans page d'accueil, et fait découvrir une entrée aléatoire encore inconnue à chaque page consommée ;
|
||||
- garde les découvertes du Livre interdit personnelles en main et partage temporairement celles du propriétaire uniquement lorsque le livre est posé sur un pupitre ;
|
||||
- conserve les IDs et les anciennes données partagées, ainsi que les données v3 et le protocole réseau 4 d'It's Alive, sans migration de sauvegarde.
|
||||
- actualise JEI de `30.28.0.191` à la bêta compatible `30.28.0.193` dans Packwiz et le manifeste Prism/Modrinth ;
|
||||
- distribue les quatre JAR mis à jour dans Packwiz, Prism et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde.
|
||||
|
||||
## `26.2.0-alpha.209`
|
||||
|
||||
Module modifié : **It's Alive `0.0.0-alpha.34`**. Les neuf autres modules conservent leur version issue de l’alpha.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 l’alpha.208 vers l’état allumé et porte les données It's Alive en v3 sans changer le protocole réseau 4 ;
|
||||
- remplace l’ancien 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 l’alpha.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 l’alpha.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 l’interface et les affiche sous forme compacte au-delà de 999 ;
|
||||
- conserve l’accè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 l’attaquant afin d’éviter de générer un nombre non borné d’objets ;
|
||||
- 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 l’alpha.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 l’alpha.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 l’alpha.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 n’importe 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 l’alpha.203.
|
||||
|
||||
- charge la texture 16×16 du Safe Block depuis l’atlas de blocs afin de supprimer la texture manquante avant formation ;
|
||||
- refuse explicitement les lignes horizontales ou verticales de 3 × 1 et n’active le CTM 48×48 qu’après validation des neuf blocs du mur 3 × 3 ;
|
||||
- décale le panneau d’inventaire étendu sous les trois lignes monétaires afin de rendre l’émeraude visible et laisse Sanctuary afficher un seul libellé d’inventaire ;
|
||||
- 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`
|
||||
|
||||
Module modifié : **Another World `0.0.0-alpha.51`**. Les neuf autres modules conservent leur version issue de l’alpha.202.
|
||||
|
||||
- corrige la structure du Safe Block : les neuf segments doivent former un mur vertical exact de 3 × 3, et non une plateforme qui apparaît en 3 × 1 de face ;
|
||||
- conserve un modèle cubique individuel en main et tant que la structure est incomplète ;
|
||||
- active uniquement après formation les neuf modèles CTM qui découpent les textures 48×48 du coffre-fort ;
|
||||
- remplace le coffre vanilla à 27 cases par une interface dédiée à trois soldes centrés — rubis, saphirs et émeraudes — avec les boutons `− ×64`, `− ×1`, `+ ×1` et `+ ×64` ;
|
||||
- valide tous les transferts côté serveur, utilise seulement les rangées Sanctuary débloquées et refuse un retrait plutôt que de jeter des gemmes lorsque l’inventaire est plein ;
|
||||
- actualise JEI de `30.26.0.186` à `30.27.0.187` dans Packwiz et le manifeste Prism/Modrinth ;
|
||||
- préserve les soldes existants et le stockage persistant à 27 piles, porte Another World de la donnée v2 à v3 et `safe_vaults` de v1 à v2 ; une ancienne plateforme horizontale doit être reconstruite verticalement.
|
||||
|
||||
## `26.2.0-alpha.202`
|
||||
|
||||
Modules modifiés : **Another World `0.0.0-alpha.50`**, **It's Alive `0.0.0-alpha.31`**, **I Like To Move It `0.0.0-alpha.14`** et **Sanctuary `0.0.0-alpha.117`**. Les six autres modules conservent leur version issue de l’alpha.201.
|
||||
|
||||
- ajoute `anotherworld:safe_block`, crafté avec huit blocs d’or autour d’un coffre, et active un unique coffre-fort par joueur lorsque neuf blocs du même propriétaire forment un carré horizontal exact de 3 × 3 ;
|
||||
- conserve 27 emplacements globaux pour rubis, saphirs et émeraudes sous la clé versionnée `anotherworld_safe_vaults`, ce qui garde le solde accessible au Shop lorsque le chunk de la structure est déchargé ;
|
||||
- expose dans Sanctuary une API publique de fournisseurs de monnaie et relie explicitement `Another World → Sanctuary` dans Gradle et Fabric, sans cycle ni copie des règles du Shop ;
|
||||
- ajoute `anotherworld:titanium_drill` avec sa recette en titane, redstone, piston, blocs de fer et pioche en diamant ;
|
||||
- impose un forage serveur continu de 60 secondes sur un coffre ennemi dont le propriétaire est connecté, alerte celui-ci, consomme la foreuse en cas de réussite et transfère 20 % des gemmes au pillard ;
|
||||
- protège le coffre pendant 24 heures après un pillage réussi, bloque les attaques concurrentes et interdit la casse par un tiers ou le démontage d’un coffre rempli ;
|
||||
- autorise la descente directe par un ascenseur de Waystones vers l’étage immédiatement inférieur encore inconnu et le découvre après l’arrivée ;
|
||||
- exige toujours une découverte préalable pour monter par l’ascenseur ou emprunter un trajet Waystone ordinaire ;
|
||||
- compte et consomme les rubis, saphirs et émeraudes du Shop et du marché noir dans toutes les rangées Sanctuary débloquées, y compris les rangées d’overflow, sans compter deux fois une pile visible par plusieurs vues ;
|
||||
- permet de poser le livre de recettes culinaires It’s Alive sur un pupitre vanilla et d’y consulter le catalogue appris par le joueur qui l’a placé ;
|
||||
- efface cette inscription lorsque le livre est retiré ou le pupitre cassé, afin que le livre redevienne personnel dans l’inventaire ;
|
||||
- ajoute l’infobulle FR/EN/RU du livre partageable et le tag vanilla `minecraft:lectern_books` ;
|
||||
- porte les données Another World de v1 à v2 pour la nouvelle sauvegarde et conserve les données It’s Alive v1, I Like To Move It v3 et Sanctuary v11, leurs protocoles 1, 3, 5 et 21 ainsi que les sauvegardes existantes.
|
||||
|
||||
## `26.2.0-alpha.201`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.116`**. Les neuf autres modules conservent leur version issue de l’alpha.200.
|
||||
|
||||
- éclaircit le panneau communautaire avec une palette rose poudré moins saturée, sans changer ses silhouettes 32×32/16×16, son alpha ni ses IDs ;
|
||||
- affiche le pseudonyme du demandeur avec sa couleur d’équipe persistée, sur le panneau comme dans les détails du chat ;
|
||||
- remplace la commande cliquable par une fenêtre de confirmation et un payload borné, tout en revalidant côté serveur la distance, le panneau, le propriétaire, le financement et la réservation ;
|
||||
- retire réellement la récompense des rangées d’inventaire débloquées lors de la publication, conserve les composants exacts des piles sous séquestre et rembourse l’ancien dépôt par Mailbox lors d’une modification ou de la casse du panneau ;
|
||||
- compte automatiquement les éliminations KILL de mobs et de joueurs, termine BRING au clic droit en envoyant les objets au demandeur et termine PAY quand le joueur désigné confirme sur le panneau ;
|
||||
- expédie une seule fois la récompense séquestrée vers la Mailbox du preneur à la complétion et conserve un état terminé récupérable par le propriétaire ;
|
||||
- migre monotoniquement `quest_requests` de la donnée v2 à v3 ; les demandes alpha.199 existantes restent lisibles mais leur auteur doit les modifier pour déposer la récompense ;
|
||||
- porte le protocole Sanctuary de 20 à 21 et conserve les données Sanctuary v11, les formats antérieurs et tous les IDs de registre.
|
||||
|
||||
## `26.2.0-alpha.200`
|
||||
|
||||
Modules modifiés : **Ambiance `0.0.0-alpha.12`** et **Ouch `0.0.0-alpha.9`**. Les huit autres modules conservent leur version issue de l’alpha.199.
|
||||
|
||||
- relie la TNT météo d’Ouch à l’API publique d’Ambiance par une dépendance Gradle et Fabric explicite `Ouch → Ambiance` ;
|
||||
- remplace la manipulation directe de la météo vanilla par un profil Ambiance clair temporaire ;
|
||||
- maintient ce temps clair pendant `864 000` ticks, soit douze heures à 20 ticks par seconde, sans laisser le calendrier météo quotidien le remplacer pendant cette durée ;
|
||||
- fait disparaître progressivement la pluie et l’orage en cours au lieu de couper instantanément leurs intensités ;
|
||||
- applique le profil à toutes les dimensions serveur compatibles avec la météo et conserve les commandes météo d’Ambiance ;
|
||||
- préserve `ouch:weather_tnt`, ses deux alias historiques, les formats de sauvegarde, les données Ambiance v2 et Ouch v1 ainsi que leurs protocoles 4 et 1 ;
|
||||
- ne demande aucune migration de sauvegarde et actualise les manifestes de distribution Packwiz, Prism et release.
|
||||
|
||||
## `26.2.0-alpha.199`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.115`**. Les neuf autres modules conservent leur version issue de l’alpha.198.
|
||||
|
||||
- donne au panneau communautaire de demandes une texture rose framboise dédiée, sans changer ses IDs debout et mural ;
|
||||
- réduit le texte visible sur le panneau au demandeur, à l’état disponible/prise et à l’invitation à lire les détails ;
|
||||
- remplace la réservation immédiate par un aperçu détaillé dans le chat puis un bouton cliquable de confirmation ;
|
||||
- revalide lors de cette confirmation la distance, la dimension, le panneau physique, le propriétaire et l’absence de réservation concurrente ;
|
||||
- complète l’éditeur avec une cible KILL de type mob ou joueur et conserve BRING sur un item et PAY sur un joueur ;
|
||||
- ajoute une récompense promise obligatoire pour les nouvelles demandes, sous forme de don, paiement ou prêt d’un item et d’une quantité validés côté serveur ;
|
||||
- présente séparément dans le chat le demandeur, l’objectif, la mission, la cible, la quantité, la récompense et l’éventuel preneur ;
|
||||
- migre monotoniquement `quest_requests` de la donnée v1 à v2 ; les anciennes demandes restent lisibles avec une récompense non renseignée ;
|
||||
- porte le protocole Sanctuary de 19 à 20 pour les nouveaux champs bornés, sans automatiser la remise de récompense, le retour d’un prêt ou la complétion ;
|
||||
- conserve les données Sanctuary v11, tous les IDs de registre et les sauvegardes existantes.
|
||||
|
||||
## `26.2.0-alpha.198`
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.30`** et **Sanctuary `0.0.0-alpha.114`**. Les huit autres modules restent inchangés.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sanctuary 26.2
|
||||
|
||||
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.155.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
|
||||
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.158.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
|
||||
|
||||
Auteur : **KOKA99CAB**
|
||||
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
|
||||
@@ -10,9 +10,9 @@ 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 |
|
||||
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
|
||||
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
|
||||
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
|
||||
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
|
||||
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
|
||||
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
|
||||
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
|
||||
@@ -22,7 +22,47 @@ 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 local : **`26.2.0-alpha.198`**. It's Alive passe en **`0.0.0-alpha.30`** et Sanctuary en **`0.0.0-alpha.114`** ; les huit autres modules restent inchangés.
|
||||
Version courante du pack distribué : **`26.2.0-alpha.226`**. Cette release distribue Sanctuary **`0.0.0-alpha.130`** ; les neuf autres modules conservent leur version issue de l'alpha.225.
|
||||
|
||||
L’alpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, d’expérience, de lootbox ni de progression de quête, les seize variantes Canaplia survivent à tout leur cycle de culture, et le Magic Carpet retrouve la texture historique du Totem de Notch à la place de la laine violette. Les IDs, sauvegardes, formats de données et protocoles restent compatibles.
|
||||
|
||||
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
|
||||
|
||||
L’alpha.224 ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox. Le clic molette trie aussi les coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, ou l'inventaire du joueur lorsqu'il est survolé, sans déplacer la hotbar. Le protocole Sanctuary passe à 23 : client et serveur doivent utiliser l'alpha.224.
|
||||
|
||||
L’alpha.223 ajoute le tri natif des rangées de stockage par clic molette sans déplacer la hotbar ni les emplacements spéciaux. Le suivi des quêtes propose désormais le texte transparent aligné sous la colonne gauche du F3 ou le panneau original de Chris, avec bascule configurable sur `N` et contrats ordonnés par récompense. JEI passe à la bêta Fabric 26.2 `30.29.0.198`.
|
||||
|
||||
L’alpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions d’IDs d’items et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
|
||||
|
||||
L’alpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
|
||||
|
||||
L’alpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées d’inventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans l’ordre de découverte avant de passer au suivant.
|
||||
|
||||
L’alpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
|
||||
|
||||
L’alpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants d’items restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
|
||||
|
||||
L’alpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par l’alpha.221, qui corrige directement la conversion stéréo vers mono.
|
||||
|
||||
L’alpha.216 corrige la lecture lente et dégradée du disque blanc : les quatre tampons PCM initiaux de Minecraft sont maintenant remplis continûment à 48 kHz, avec une vitesse OpenAL normale, au lieu d’être majoritairement complétés par du silence. Les packs Modrinth, Prism et Packwiz sont mis à jour sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
L’alpha.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 l’attaquant sans créer des millions d’entités ou de piles d’objets.
|
||||
|
||||
L’alpha.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 s’active plus et doit être démonté puis reconstruit ; les comptes et soldes existants restent conservés.
|
||||
|
||||
L’alpha.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. L’interface monétaire affiche désormais correctement l’émeraude et place l’inventaire étendu sous les trois lignes de monnaie.
|
||||
|
||||
L’alpha.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`.
|
||||
|
||||
L’alpha.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 l’overflow. 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 26 blocs Another World disposés en coque creuse de 3 × 3 × 3 autour d’un 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 d’un 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.
|
||||
|
||||
L’alpha.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.
|
||||
|
||||
L’alpha.200 fait passer la TNT météo d’Ouch par l’API publique d’Ambiance. Son explosion impose un temps clair pendant douze heures, laisse la pluie et l’orage disparaître progressivement et empêche le profil quotidien de reprendre la main avant l’expiration. Les IDs historiques, les sauvegardes et les protocoles restent inchangés.
|
||||
|
||||
L’alpha.199 rend le panneau communautaire immédiatement identifiable grâce à une texture rose dédiée et garde son texte minimal. Un clic droit affiche désormais dans le chat l’objectif complet, la cible mob/item/joueur et la récompense promise sous forme de don, paiement ou prêt ; la quête n’est prise qu’après confirmation sur le bouton du chat. Les contrats restent communautaires : le serveur valide et conserve leurs termes sans simuler une remise, une restitution ou une complétion automatique.
|
||||
|
||||
L’alpha.198 borne l’automatisation du tonneau de fermentation : chaque emplacement n’accepte qu’une unité utile à une recette encore réalisable, les doublons invalides sont refusés et les observateurs suivent les changements visibles du contenu sans impulsion de progression parasite. Elle intègre aussi la correction de Chris : le shift-clic de l’inventaire suit le comportement vanilla sur toutes les rangées débloquées, y compris les rangées 5 et 6, tandis qu’une rangée de stockage ou d’overflow n’alimente que la hotbar.
|
||||
|
||||
@@ -34,7 +74,7 @@ L’alpha.195 charge le catalogue Shop depuis le CSV rééquilibré et conserve
|
||||
|
||||
L’alpha.194 ouvre la cuisine avec huit cultures supplémentaires — riz, soja, haricot, pois chiche, piment, basilic, menthe et coriandre — ainsi que le café, la vanille, l’olivier et le citronnier. Le riz pousse réellement immergé sur de la boue, le haricot se récolte jeune ou sec, le café se torréfie et la vanille se sèche au smoker. Le climat détermine seulement la découverte : les nouvelles plantes sauvages apparaissent dans les nouveaux chunks, tandis qu’une chance rare de semence sur la végétation permet de continuer un monde existant. Aucune réinitialisation de sauvegarde n’est requise.
|
||||
|
||||
Le [wiki cuisine de l’alpha.197](itsalive/FOOD_WIKI.md) détaille les cultures, les six postes, l’automatisation, les ingrédients transformés, le livre et ses 64 recettes.
|
||||
Le [wiki cuisine de l’alpha.199](itsalive/FOOD_WIKI.md) détaille les cultures, les six postes, l’automatisation, les ingrédients transformés, le livre et ses 64 recettes.
|
||||
|
||||
L’alpha.193 donne à chaque item du Shop une petite variation de prix stable et reproductible, tout en réservant des tarifs explicites aux vrais objets exceptionnels. L’œuf doré, porte d’entrée extrêmement rare vers les poules à gemmes, coûte désormais 811 rubis ou 59 saphirs dans le catalogue. Les six emplacements verrouillés occupent directement les six cartes restantes de la grille : ils se débloquent toujours dans l’ordre pour 2, 4, 8, 12, 18 puis 25 niveaux. L’aide de boîte aux lettres remonte légèrement et la bourse rubis/saphirs s’ancre en bas à droite.
|
||||
|
||||
@@ -76,6 +116,8 @@ L’alpha.173 centralise l’audio du Miner : une seule boucle de frappe sur le
|
||||
|
||||
L’alpha.171 rassemble les **46 capes** dans un onglet créatif dédié « Capes ». Elles ne sont plus mélangées à l’onglet vanilla Tools & Utilities et ne sont donc affichées qu’une seule fois.
|
||||
|
||||
L’alpha.226 ajoute une 47e cape, `sanctuary:white_cape`, distincte de la collection Only Fun. Elle se fabrique avec six laines blanches en deux colonnes de trois, puis accepte une bannière dans une recette simple pour en conserver la couleur et jusqu’à six motifs ; les motifs s’affichent dans l’inventaire comme une fois la cape équipée.
|
||||
|
||||
L’alpha.170 ajoute les pictogrammes fournis aux emplacements vides de la cape et du familier. Ils disparaissent dès qu’une cape ou un œuf est équipé, sans modifier les objets, la persistance ni le protocole.
|
||||
|
||||
L’alpha.169 agrandit la collection de 13 à **46 capes équipables**. Les 33 nouvelles textures 64×32 fournies sont conservées telles quelles, avec une icône d’inventaire, un modèle et des traductions FR/EN/RU pour chaque cape. Elles utilisent le slot, la persistance et le rendu multijoueur déjà existants, restent sans recette et rejoignent toutes la lootbox de capes Only Fun. Les caméras Canon restent accessibles dans la lootbox normale et peuvent désormais aussi apparaître directement, très rarement, dans les coffres d’exploration et du donjon de cobblestone.
|
||||
@@ -309,7 +351,7 @@ Le fog of war distingue le fond de biomes calculé depuis la seed, volontairemen
|
||||
|
||||
## Waystones locaux
|
||||
|
||||
I Like To Move It possède désormais `iliketomoveit:waystone`. Chaque joueur doit visiter physiquement une pierre pour la découvrir : seules ses pierres découvertes apparaissent dans le réseau et dans l'Atlas. Dans l'écran, `R` renomme la pierre ouverte, `Entrée` valide et `Échap` annule. Un clic droit ouvre les destinations découvertes de la dimension courante, triées par distance ; le serveur facture un niveau d'XP par tranche commencée de 512 blocs, cherche une arrivée sûre puis applique sons et particules. Deux pierres ou plus sur les mêmes coordonnées X/Z deviennent un ascenseur vertical gratuit et ne se mélangent pas au réseau ordinaire. Sur cet ascenseur, s'accroupir descend vers l'étage découvert immédiatement inférieur et sauter depuis la pierre monte vers l'étage découvert immédiatement supérieur, sans ouvrir l'interface.
|
||||
I Like To Move It possède désormais `iliketomoveit:waystone`. Chaque joueur doit visiter physiquement une pierre pour la découvrir : seules ses pierres découvertes apparaissent dans le réseau et dans l'Atlas. Dans l'écran, `R` renomme la pierre ouverte, `Entrée` valide et `Échap` annule. Un clic droit ouvre les destinations découvertes de la dimension courante, triées par distance ; le serveur facture un niveau d'XP par tranche commencée de 512 blocs, cherche une arrivée sûre puis applique sons et particules. Deux pierres ou plus sur les mêmes coordonnées X/Z deviennent un ascenseur vertical gratuit et ne se mélangent pas au réseau ordinaire. Sur cet ascenseur, s'accroupir descend vers l'étage immédiatement inférieur même s'il n'était pas encore découvert, puis le découvre à l'arrivée ; sauter depuis la pierre monte uniquement vers l'étage découvert immédiatement supérieur, sans ouvrir l'interface.
|
||||
|
||||
Le déblocage `waystones` dans Progression est obligatoire hors mode créatif. Le serveur ne fait confiance à aucune distance, aucun coût ni aucune cible envoyés par le client. Les anciennes pierres `waystone:waystone` restent chargeables et l'index `waystone:waystones` est copié une seule fois vers la donnée v2 `iliketomoveit:waystones`, sans réécrire la source. L'ancien JAR Waystone ne doit donc plus être chargé en parallèle, car I Like To Move It réserve son ID historique comme alias de sauvegarde.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Textures historiques Golden Days dans Alpha
|
||||
|
||||
Ambiance peut charger les packs officiels Golden Days uniquement pendant que
|
||||
le client se trouve dans `sanctuary:alpha`. Aucun fichier, texture ou dérivé de
|
||||
Golden Days n'est distribué dans les JAR ou dans le `.mrpack` Sanctuary.
|
||||
|
||||
## Installation cliente facultative
|
||||
|
||||
1. Télécharger [Golden Days Base](https://www.curseforge.com/minecraft/texture-packs/golden-days)
|
||||
depuis sa page officielle.
|
||||
2. Télécharger [Golden Days Alpha](https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha)
|
||||
depuis sa page officielle.
|
||||
3. Placer les deux ZIP sans les extraire dans le dossier `resourcepacks/` du
|
||||
client. Les noms doivent conserver les marqueurs `golden-days-base` et
|
||||
`golden-days-alpha` ; les espaces et `_` sont aussi reconnus.
|
||||
4. Laisser les deux packs désactivés dans l'écran vanilla. Ambiance les compose
|
||||
temporairement dans l'ordre **Base**, puis **Alpha**.
|
||||
|
||||
À l'entrée dans Alpha, Ambiance capture la pile sélectionnée exacte, ajoute les
|
||||
deux packs en mémoire et recharge les ressources. À la sortie ou à la
|
||||
déconnexion, la pile capturée est restaurée exactement. Le fichier d'options
|
||||
vanilla n'est jamais modifié : après une interruption ou un crash pendant un
|
||||
rechargement, le prochain démarrage retrouve donc la pile enregistrée.
|
||||
|
||||
Si un des deux packs manque ou si un rechargement échoue, le client reste
|
||||
jouable avec les textures vanilla et affiche un message lisible. Un serveur
|
||||
dédié n'a besoin d'aucun ZIP.
|
||||
|
||||
## Licence et redistribution
|
||||
|
||||
Golden Days est publié sous **All Rights Reserved**. Sa
|
||||
[licence officielle](https://poeticrainbow.github.io/golden-days/license/)
|
||||
interdit notamment de redistribuer ou de reconditionner les ressources sans
|
||||
autorisation. Sanctuary ne fournit que l'intégration locale ci-dessus et les
|
||||
liens officiels. Les archives de développement restent hors du dépôt et de
|
||||
tous les artefacts distribués.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Third-party notices
|
||||
|
||||
## Golden Days Base and Golden Days Alpha
|
||||
|
||||
Optional client resource packs by PoeticRainbow. No Golden Days asset is
|
||||
included in Ambiance or Sanctuary.
|
||||
|
||||
- Base project: https://www.curseforge.com/minecraft/texture-packs/golden-days
|
||||
- Alpha project: https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha
|
||||
- License: https://poeticrainbow.github.io/golden-days/license/
|
||||
|
||||
Golden Days is All Rights Reserved. Players obtain the two archives separately
|
||||
from the official project pages for local use.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Disque blanc YouTube
|
||||
|
||||
Le disque blanc appartient à **Ambiance**, propriétaire de l’audio positionnel. Il conserve l’ID
|
||||
`ambiance:white_disc` et se fabrique avec un disque 13 entouré de huit colorants blancs. Pour le
|
||||
lire, il faut le renommer dans une enclume avec une URL YouTube HTTPS puis l’insérer dans un
|
||||
jukebox vanilla.
|
||||
|
||||
## Contrat réseau et sécurité
|
||||
|
||||
- le serveur reste l’autorité de l’insertion et refuse les noms qui ne correspondent pas à une URL
|
||||
HTTPS exacte de `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com` ou
|
||||
`youtu.be` ;
|
||||
- les formats `watch`, `shorts`, `embed`, `live` et le lien court sont acceptés, mais l’identifiant
|
||||
doit contenir exactement onze caractères ASCII autorisés ;
|
||||
- le payload S2C ne contient que la position du jukebox et cet identifiant borné, jamais une URL
|
||||
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
|
||||
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
|
||||
trente minutes, et limite les lectures simultanées à quatre ;
|
||||
- la source AAC stéréo est décodée à 48 kHz (48 000 Hz) puis convertie explicitement en PCM mono 16 bits
|
||||
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
|
||||
vitesse et la hauteur originales tout en restant positionnelle ;
|
||||
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
|
||||
64 blocs. La lecture s’arrête lorsque le disque est éjecté, le chunk disparaît ou la session se
|
||||
ferme.
|
||||
|
||||
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
|
||||
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
|
||||
|
||||
## Dépendances clientes embarquées
|
||||
|
||||
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et sur le commit youtube-source
|
||||
`f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3` (MIT), épinglé pour intégrer les correctifs de lecture
|
||||
YouTube publiés après `1.18.2`. Le profil iOS corrigé est utilisé sans compte, OAuth, jeton privé ou
|
||||
service de chiffrement tiers. Les dépendances sont placées en JAR imbriqués par Loom et leurs avis
|
||||
de licence restent dans les archives d’origine. L’accès YouTube est effectué uniquement par le
|
||||
client et peut cesser de fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou
|
||||
URL arbitraire n’est acceptée par le serveur.
|
||||
@@ -1,8 +1,59 @@
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"
|
||||
|
||||
configurations {
|
||||
whiteDiscLibraries
|
||||
implementation.extendsFrom whiteDiscLibraries
|
||||
include.extendsFrom whiteDiscLibraries
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':sanctuary')
|
||||
compileOnly "maven.modrinth:YL57xq9U:oaD6KQls"
|
||||
whiteDiscLibraries("dev.arbjerg:lavaplayer:2.2.7") {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
exclude group: "org.jetbrains", module: "annotations"
|
||||
}
|
||||
whiteDiscLibraries("dev.lavalink.youtube:v2:${youtubeSourceVersion}") {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
exclude group: "org.jetbrains", module: "annotations"
|
||||
}
|
||||
[
|
||||
"dev.arbjerg:lavaplayer-natives:2.2.7",
|
||||
"dev.arbjerg:lava-common:2.2.7",
|
||||
"dev.lavalink.youtube:common:${youtubeSourceVersion}",
|
||||
"org.mozilla:rhino-engine:1.7.15",
|
||||
"org.mozilla:rhino:1.7.15",
|
||||
"commons-io:commons-io:2.13.0",
|
||||
"org.jsoup:jsoup:1.16.1",
|
||||
"net.iharder:base64:2.3.9",
|
||||
"org.json:json:20240303",
|
||||
"org.apache.httpcomponents:httpclient:4.5.14",
|
||||
"org.apache.httpcomponents:httpcore:4.4.16",
|
||||
"commons-logging:commons-logging:1.2",
|
||||
"commons-codec:commons-codec:1.11",
|
||||
"com.fasterxml.jackson.core:jackson-core:2.15.2",
|
||||
"com.fasterxml.jackson.core:jackson-databind:2.15.2",
|
||||
"com.fasterxml.jackson.core:jackson-annotations:2.15.2",
|
||||
"com.grack:nanojson:1.7"
|
||||
].each { coordinates ->
|
||||
whiteDiscLibraries(coordinates) { transitive = false }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("jar") {
|
||||
from("THIRD_PARTY_NOTICES.md") {
|
||||
rename { "THIRD_PARTY_NOTICES_ambiance.md" }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("ambianceVisualApiSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks bounded visual-effect requests exposed to gameplay modules."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualApiSmoke"
|
||||
}
|
||||
|
||||
tasks.register("ambianceScheduleSmoke", JavaExec) {
|
||||
@@ -13,15 +64,49 @@ tasks.register("ambianceScheduleSmoke", JavaExec) {
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.environment.AmbianceScheduleSmoke"
|
||||
}
|
||||
|
||||
tasks.register("whiteDiscUrlSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks strict and bounded YouTube URL parsing for the white disc."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke"
|
||||
}
|
||||
|
||||
tasks.register("whiteDiscPlaybackSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Manually checks live YouTube loading and PCM decoding for the white disc."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscPlaybackSmoke"
|
||||
}
|
||||
|
||||
tasks.register("whiteDiscStreamSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks continuous 48 kHz PCM buffering for Minecraft's white-disc channel."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.LavaplayerAudioStreamSmoke"
|
||||
}
|
||||
|
||||
tasks.register("alphaTexturePackManagerSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks local Golden Days detection, ordering and exact stack preservation."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.AlphaTexturePackManagerSmoke"
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
tasks.register("verifyAmbiance") {
|
||||
group = "verification"
|
||||
description = "Checks Ambiance alpha.11, including playable night fog and weather/lunar controls."
|
||||
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
|
||||
inputs.files(fileTree("src/main/java"))
|
||||
inputs.files(fileTree("src/main/resources"))
|
||||
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java"))
|
||||
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStreamSmoke.java"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.127-daily-ambiance-sodium-iris-to-alpha.128.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.128-ambiance-visuals-canon-iris-to-alpha.129.json"))
|
||||
@@ -60,8 +145,23 @@ tasks.register("verifyAmbiance") {
|
||||
def languages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
|
||||
[(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))]
|
||||
}
|
||||
def ambianceLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language ->
|
||||
[(language): new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${language}.json"))]
|
||||
}
|
||||
def whiteDiscTexture = javax.imageio.ImageIO.read(
|
||||
file("src/main/resources/assets/ambiance/textures/item/white_disc.png"))
|
||||
def whiteDiscRecipe = new JsonSlurper().parse(
|
||||
file("src/main/resources/data/ambiance/recipe/white_disc.json"))
|
||||
def whiteDiscSong = new JsonSlurper().parse(
|
||||
file("src/main/resources/data/ambiance/jukebox_song/white_disc.json"))
|
||||
def levelEventMixin = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelEventHandlerMixin.java").text
|
||||
def whiteDiscAudioPlayer = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java").text
|
||||
def whiteDiscPlaybackSmoke = file(
|
||||
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
|
||||
|
||||
if (project.version.toString() != "0.0.0-alpha.11"
|
||||
if (project.version.toString() != "0.0.0-alpha.22"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.125"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.126"
|
||||
|| migration.target?.modules?.ambiance != "0.0.0-alpha.1"
|
||||
@@ -76,7 +176,7 @@ tasks.register("verifyAmbiance") {
|
||||
|| migration.compatibility?.ambiance_data_version != 1
|
||||
|| migration.compatibility?.ambiance_network_protocol != 2
|
||||
|| manifest.custom?.sanctuary26?.data_version != 2
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 4
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 6
|
||||
|| dailyMigration.target?.pack_version != "26.2.0-alpha.128"
|
||||
|| dailyMigration.target?.modules?.ambiance != "0.0.0-alpha.2"
|
||||
|| dailyMigration.lunar_schedule?.cycle != "minecraft_native_eight_days"
|
||||
@@ -247,13 +347,67 @@ tasks.register("verifyAmbiance") {
|
||||
|| !javaText.contains('clearLunarNightOverride')) {
|
||||
throw new GradleException("Ambiance alpha.10 command override contract is incomplete")
|
||||
}
|
||||
if (dungeonMigration.target?.modules?.ambiance != project.version.toString()
|
||||
if (dungeonMigration.target?.modules?.ambiance != "0.0.0-alpha.11"
|
||||
|| dungeonMigration.gameplay?.night_fog_visibility != 0.92
|
||||
|| dungeonMigration.gameplay?.night_fog_color_scale != 0.98
|
||||
|| !javaText.contains("Mth.lerp(night, 0.98F, 0.92F)")
|
||||
|| !javaText.contains("Mth.lerp(night, 1.0F, 0.98F)")) {
|
||||
throw new GradleException("Ambiance alpha.11 playable-night fog contract is incomplete")
|
||||
}
|
||||
if (!javaText.contains('id("white_disc_playback")')
|
||||
|| !javaText.contains('readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)')
|
||||
|| !javaText.contains('PlayerLookup.tracking(level, pos)')
|
||||
|| !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)')
|
||||
|| !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4')
|
||||
|| !javaText.contains('PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()')
|
||||
|| !javaText.contains('MAXIMUM_START_WAIT_TICKS = 100')
|
||||
|| !javaText.contains('tickPendingStarts(client)')
|
||||
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|
||||
|| !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L')
|
||||
|| !javaText.contains('new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|
||||
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|
||||
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|
||||
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|
||||
|| !javaText.contains('initialWaitAttempted')
|
||||
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|
||||
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|
||||
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|
||||
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|
||||
|| !javaText.contains('SOURCE_CHANNELS = 2')
|
||||
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|
||||
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|
||||
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|
||||
|| whiteDiscAudioPlayer.contains('.release()')
|
||||
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
|
||||
|| !whiteDiscPlaybackSmoke.contains('PCM cadence: 100%% speed')
|
||||
|| !whiteDiscPlaybackSmoke.contains('stream.decodedTimelineMillis() != 4_000L')
|
||||
|| !whiteDiscPlaybackSmoke.contains('stream.paddedSilenceBytes() != 0L')
|
||||
|| !javaText.contains('SoundSource.RECORDS')
|
||||
|| !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)')
|
||||
|| !javaText.contains('LONG_HOSTS = Set.of(')
|
||||
|| !javaText.contains('"https".equalsIgnoreCase(uri.getScheme())')
|
||||
|| !file("src/main/resources/assets/ambiance/items/white_disc.json").isFile()
|
||||
|| !file("src/main/resources/assets/ambiance/models/item/white_disc.json").isFile()
|
||||
|| whiteDiscTexture == null || whiteDiscTexture.width != 16 || whiteDiscTexture.height != 16
|
||||
|| whiteDiscRecipe.result?.id != "ambiance:white_disc"
|
||||
|| whiteDiscRecipe.key?.W != "minecraft:white_dye"
|
||||
|| whiteDiscSong.sound_event != "minecraft:intentionally_empty"
|
||||
|| whiteDiscSong.length_in_seconds != 1800
|
||||
|| !levelEventMixin.contains('@Mixin(LevelEventHandler.class)')
|
||||
|| !levelEventMixin.contains('@Inject(method = "levelEvent"')
|
||||
|| !levelEventMixin.contains('ambiance$stopWhiteDisc(int type, BlockPos pos, int data,')
|
||||
|| ambianceLanguages.any { locale, translations ->
|
||||
!["item.ambiance.white_disc", "tooltip.ambiance.white_disc",
|
||||
"message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every {
|
||||
translations.containsKey(it)
|
||||
}
|
||||
}
|
||||
|| !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7')
|
||||
|| !file("build.gradle").text.contains(
|
||||
'def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"')
|
||||
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:${youtubeSourceVersion}')) {
|
||||
throw new GradleException("Ambiance white-disc playback contract is incomplete")
|
||||
}
|
||||
|
||||
[
|
||||
'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology',
|
||||
@@ -265,6 +419,8 @@ tasks.register("verifyAmbiance") {
|
||||
'class AmbianceEnvironmentController', 'enum AmbianceDailyWeather',
|
||||
'enum AmbianceCloudCover', 'enum AmbianceLunarNight',
|
||||
'id("environment_sync")', 'setRainLevel(weather.rainIntensity())',
|
||||
'ClientboundGameEventPacket.RAIN_LEVEL_CHANGE',
|
||||
'WEATHER_FADE_EPSILON',
|
||||
'Math.multiplyExact(worldDay, MINECRAFT_DAY_TICKS)',
|
||||
'class AmbianceEffects', 'class AmbianceBiomeTints',
|
||||
'AmbianceShaderCompatibility.isShaderPackActive()', 'case LIGHT_RAIN -> 0.72F',
|
||||
@@ -299,7 +455,53 @@ tasks.register("verifyAmbiance") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyAlphaTextures") {
|
||||
group = "verification"
|
||||
description = "Checks the alpha.21 local-only Golden Days overlay and exact restore contract."
|
||||
inputs.file(file("GOLDEN_DAYS.md"))
|
||||
inputs.file(file("THIRD_PARTY_NOTICES.md"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java"))
|
||||
inputs.files(fileTree("src/main/resources/assets/ambiance/lang") { include "*.json" })
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
doLast {
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
def manager = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java").text
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${it}.json"))
|
||||
}
|
||||
def forbiddenArchives = rootProject.fileTree(rootProject.projectDir) {
|
||||
include "**/*golden*days*.zip", "**/*golden*days*.jar"
|
||||
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
|
||||
}.files
|
||||
if (project.version.toString() != "0.0.0-alpha.22"
|
||||
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|
||||
|| migration.alpha_textures?.client_only != true
|
||||
|| migration.alpha_textures?.bundled_assets != false
|
||||
|| migration.alpha_textures?.overlay_order != ["base", "alpha"]
|
||||
|| migration.alpha_textures?.previous_selection_restored_exactly != true
|
||||
|| migration.alpha_textures?.saved_options_modified != false
|
||||
|| migration.alpha_textures?.dedicated_server_files_required != false
|
||||
|| !manager.contains("SanctuaryDimensions.ALPHA")
|
||||
|| !manager.contains("previousSelection = List.copyOf")
|
||||
|| !manager.contains("selectionWithOverlay(previousSelection")
|
||||
|| manager.contains("updateResourcePacks")
|
||||
|| locales.any { !it.containsKey("message.ambiance.alpha_textures_missing")
|
||||
|| !it.containsKey("message.ambiance.alpha_textures_restore_failed") }
|
||||
|| !forbiddenArchives.isEmpty()
|
||||
|| !file("THIRD_PARTY_NOTICES.md").text.contains("No Golden Days asset")) {
|
||||
throw new GradleException("Ambiance alpha.21 Golden Days overlay contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifyAmbiance")
|
||||
dependsOn tasks.named("ambianceScheduleSmoke")
|
||||
dependsOn tasks.named("ambianceVisualApiSmoke")
|
||||
dependsOn tasks.named("whiteDiscUrlSmoke")
|
||||
dependsOn tasks.named("whiteDiscStreamSmoke")
|
||||
dependsOn tasks.named("alphaTexturePackManagerSmoke")
|
||||
dependsOn tasks.named("verifyAlphaTextures")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceBloodNightAssault;
|
||||
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceWeatherCommands;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceNetworking;
|
||||
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
|
||||
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscService;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.slf4j.Logger;
|
||||
@@ -29,6 +30,7 @@ public final class AmbianceMod implements ModInitializer {
|
||||
public void onInitialize() {
|
||||
AmbianceRegistries.initialize();
|
||||
AmbianceNetworking.initialize();
|
||||
WhiteDiscService.initialize();
|
||||
RealtimeMod.initialize();
|
||||
AmbianceEnvironmentController.initialize();
|
||||
AmbianceFirstSpawnVision.initialize();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.api.environment;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentController;
|
||||
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentSnapshot;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
|
||||
/** Server-owned sky/calendar snapshot for dependent harvest rules. */
|
||||
public final class AmbianceLunarHarvestApi {
|
||||
public static final int CONTRACT_VERSION = 1;
|
||||
|
||||
public record Conditions(
|
||||
boolean night,
|
||||
boolean skyVisible,
|
||||
boolean skyDimension,
|
||||
AmbianceEnvironmentSnapshot environment
|
||||
) {
|
||||
public boolean compatible() {
|
||||
return night && skyVisible && skyDimension;
|
||||
}
|
||||
}
|
||||
|
||||
private AmbianceLunarHarvestApi() {}
|
||||
|
||||
public static Conditions evaluate(ServerLevel level, BlockPos pos) {
|
||||
long clock = Math.floorMod(level.getDefaultClockTime(), 24_000L);
|
||||
boolean night = clock >= 13_000L && clock <= 23_000L;
|
||||
boolean skyDimension = level.dimensionType().hasSkyLight() && !level.dimensionType().hasFixedTime();
|
||||
return new Conditions(night, skyDimension && level.canSeeSky(pos.above()), skyDimension,
|
||||
AmbianceEnvironmentController.snapshot(level.getServer()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.api.visual;
|
||||
|
||||
import java.util.Objects;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Public boundary used by gameplay modules that ask Ambiance to compose a
|
||||
* temporary visual treatment. Networking and rendering remain Ambiance-owned.
|
||||
*/
|
||||
public final class AmbianceVisualEffectApi {
|
||||
public static final int CONTRACT_VERSION = 1;
|
||||
public static final int MAX_DURATION_TICKS = 10 * 60 * 20;
|
||||
|
||||
private AmbianceVisualEffectApi() {
|
||||
}
|
||||
|
||||
public static Request request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
|
||||
return new Request(source, profile, remainingTicks);
|
||||
}
|
||||
|
||||
public record Request(Identifier source, AmbianceVisualProfile profile, int remainingTicks) {
|
||||
public Request {
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
remainingTicks = Math.max(0, Math.min(MAX_DURATION_TICKS, remainingTicks));
|
||||
}
|
||||
|
||||
public boolean expired() {
|
||||
return this.remainingTicks == 0;
|
||||
}
|
||||
|
||||
public Request withReducedMotion(boolean reduceMotion) {
|
||||
return reduceMotion
|
||||
? new Request(this.source, this.profile.withoutMotion(), this.remainingTicks)
|
||||
: this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.api.visual;
|
||||
|
||||
/**
|
||||
* Renderer-independent color and motion parameters composed by Ambiance.
|
||||
*
|
||||
* <p>Every value is finite and bounded at construction time so gameplay
|
||||
* modules cannot send an invasive or unbounded profile to the client.</p>
|
||||
*/
|
||||
public record AmbianceVisualProfile(
|
||||
float intensity,
|
||||
float tintRed,
|
||||
float tintGreen,
|
||||
float tintBlue,
|
||||
float tintStrength,
|
||||
float saturationDelta,
|
||||
float contrastDelta,
|
||||
float vignetteStrength,
|
||||
float ghostingStrength,
|
||||
float wobbleStrength,
|
||||
float pulseStrength
|
||||
) {
|
||||
public static final AmbianceVisualProfile NONE = new AmbianceVisualProfile(
|
||||
0.0F,
|
||||
1.0F,
|
||||
1.0F,
|
||||
1.0F,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F
|
||||
);
|
||||
|
||||
public AmbianceVisualProfile {
|
||||
intensity = unit("intensity", intensity);
|
||||
tintRed = unit("tintRed", tintRed);
|
||||
tintGreen = unit("tintGreen", tintGreen);
|
||||
tintBlue = unit("tintBlue", tintBlue);
|
||||
tintStrength = unit("tintStrength", tintStrength);
|
||||
saturationDelta = signedUnit("saturationDelta", saturationDelta);
|
||||
contrastDelta = signedUnit("contrastDelta", contrastDelta);
|
||||
vignetteStrength = unit("vignetteStrength", vignetteStrength);
|
||||
ghostingStrength = unit("ghostingStrength", ghostingStrength);
|
||||
wobbleStrength = unit("wobbleStrength", wobbleStrength);
|
||||
pulseStrength = unit("pulseStrength", pulseStrength);
|
||||
}
|
||||
|
||||
/** Removes image motion while preserving the bounded color treatment. */
|
||||
public AmbianceVisualProfile withoutMotion() {
|
||||
return new AmbianceVisualProfile(
|
||||
this.intensity,
|
||||
this.tintRed,
|
||||
this.tintGreen,
|
||||
this.tintBlue,
|
||||
this.tintStrength,
|
||||
this.saturationDelta,
|
||||
this.contrastDelta,
|
||||
this.vignetteStrength,
|
||||
0.0F,
|
||||
0.0F,
|
||||
0.0F
|
||||
);
|
||||
}
|
||||
|
||||
private static float unit(String field, float value) {
|
||||
return bounded(field, value, 0.0F, 1.0F);
|
||||
}
|
||||
|
||||
private static float signedUnit(String field, float value) {
|
||||
return bounded(field, value, -1.0F, 1.0F);
|
||||
}
|
||||
|
||||
private static float bounded(String field, float value, float minimum, float maximum) {
|
||||
if (!Float.isFinite(value)) {
|
||||
throw new IllegalArgumentException(field + " must be finite");
|
||||
}
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.packs.repository.PackRepository;
|
||||
|
||||
/**
|
||||
* Applies locally installed Golden Days packs only while the player is in
|
||||
* {@code sanctuary:alpha}. The selected stack is changed in memory only; the
|
||||
* player's saved resource-pack options remain untouched.
|
||||
*/
|
||||
public final class AlphaTexturePackManager {
|
||||
static final String BASE_MARKER = "golden-days-base";
|
||||
static final String ALPHA_MARKER = "golden-days-alpha";
|
||||
private static final int MAX_RESTORE_RETRIES = 1;
|
||||
|
||||
private static boolean requestKnown;
|
||||
private static boolean alphaRequested;
|
||||
private static boolean reloadInProgress;
|
||||
private static boolean failedForCurrentRequest;
|
||||
private static boolean missingMessageShown;
|
||||
private static boolean reconcilePending = true;
|
||||
private static int restoreFailures;
|
||||
private static List<String> previousSelection = List.of();
|
||||
private static boolean previousSelectionCaptured;
|
||||
|
||||
private AlphaTexturePackManager() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ClientTickEvents.END_CLIENT_TICK.register(AlphaTexturePackManager::tick);
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> request(client, false));
|
||||
}
|
||||
|
||||
private static void tick(Minecraft client) {
|
||||
boolean shouldUseAlphaTextures = client.level != null
|
||||
&& client.level.dimension().equals(SanctuaryDimensions.ALPHA);
|
||||
request(client, shouldUseAlphaTextures);
|
||||
if (!reloadInProgress && reconcilePending) {
|
||||
reconcilePending = false;
|
||||
reconcile(client);
|
||||
}
|
||||
}
|
||||
|
||||
private static void request(Minecraft client, boolean requested) {
|
||||
if (requestKnown && requested == alphaRequested) return;
|
||||
alphaRequested = requested;
|
||||
requestKnown = true;
|
||||
failedForCurrentRequest = false;
|
||||
missingMessageShown = false;
|
||||
restoreFailures = 0;
|
||||
if (requested && !previousSelectionCaptured) {
|
||||
PackRepository repository = client.getResourcePackRepository();
|
||||
repository.reload();
|
||||
previousSelection = List.copyOf(repository.getSelectedIds());
|
||||
previousSelectionCaptured = true;
|
||||
}
|
||||
reconcilePending = true;
|
||||
}
|
||||
|
||||
private static void reconcile(Minecraft client) {
|
||||
PackRepository repository = client.getResourcePackRepository();
|
||||
repository.reload();
|
||||
List<String> selected = List.copyOf(repository.getSelectedIds());
|
||||
|
||||
if (alphaRequested && !failedForCurrentRequest) {
|
||||
Optional<PackPair> packs = findPacks(repository.getAvailableIds());
|
||||
if (packs.isEmpty()) {
|
||||
failedForCurrentRequest = true;
|
||||
showMissingPackMessage(client);
|
||||
applyIfDifferent(client, selected, previousSelection, false);
|
||||
return;
|
||||
}
|
||||
List<String> desired = selectionWithOverlay(previousSelection, packs.orElseThrow());
|
||||
applyIfDifferent(client, selected, desired, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previousSelectionCaptured) return;
|
||||
applyIfDifferent(client, selected, previousSelection, false);
|
||||
}
|
||||
|
||||
private static void applyIfDifferent(Minecraft client, List<String> selected,
|
||||
List<String> desired, boolean enablingAlpha) {
|
||||
if (selected.equals(desired)) {
|
||||
if (!enablingAlpha && !alphaRequested) clearSnapshot();
|
||||
return;
|
||||
}
|
||||
client.getResourcePackRepository().setSelected(desired);
|
||||
reloadInProgress = true;
|
||||
AmbianceMod.LOGGER.info("[{}] Reloading client resources for Alpha texture mode: {}.",
|
||||
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enabled" : "disabled");
|
||||
client.reloadResourcePacks().whenComplete((ignored, error) -> client.execute(() -> {
|
||||
reloadInProgress = false;
|
||||
if (error != null) {
|
||||
AmbianceMod.LOGGER.error("[{}] Could not {} the Golden Days Alpha overlay.",
|
||||
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enable" : "restore", error);
|
||||
if (enablingAlpha) {
|
||||
failedForCurrentRequest = true;
|
||||
reconcilePending = true;
|
||||
} else if (++restoreFailures <= MAX_RESTORE_RETRIES) {
|
||||
reconcilePending = true;
|
||||
} else {
|
||||
client.getResourcePackRepository().setSelected(previousSelection);
|
||||
showRestoreFailureMessage(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!enablingAlpha && !alphaRequested) clearSnapshot();
|
||||
if (enablingAlpha != alphaRequested) reconcilePending = true;
|
||||
}));
|
||||
}
|
||||
|
||||
static Optional<PackPair> findPacks(Collection<String> availableIds) {
|
||||
Optional<String> base = findPack(availableIds, BASE_MARKER);
|
||||
Optional<String> alpha = findPack(availableIds, ALPHA_MARKER);
|
||||
return base.isPresent() && alpha.isPresent()
|
||||
? Optional.of(new PackPair(base.orElseThrow(), alpha.orElseThrow()))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
static List<String> selectionWithOverlay(Collection<String> previous, PackPair packs) {
|
||||
List<String> desired = selectedWithoutGoldenDays(previous);
|
||||
// Pack order is bottom-to-top: Base first, then its Alpha overlay.
|
||||
desired.add(packs.base());
|
||||
desired.add(packs.alpha());
|
||||
return List.copyOf(desired);
|
||||
}
|
||||
|
||||
private static List<String> selectedWithoutGoldenDays(Collection<String> selectedIds) {
|
||||
List<String> result = new ArrayList<>(selectedIds.size());
|
||||
for (String id : selectedIds) {
|
||||
if (!isGoldenDaysPack(id)) result.add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Optional<String> findPack(Collection<String> availableIds, String marker) {
|
||||
return availableIds.stream()
|
||||
.filter(id -> normalized(id).contains(marker))
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static boolean isGoldenDaysPack(String id) {
|
||||
String normalized = normalized(id);
|
||||
return normalized.contains(BASE_MARKER) || normalized.contains(ALPHA_MARKER);
|
||||
}
|
||||
|
||||
private static String normalized(String id) {
|
||||
return id.toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-');
|
||||
}
|
||||
|
||||
private static void clearSnapshot() {
|
||||
previousSelection = List.of();
|
||||
previousSelectionCaptured = false;
|
||||
restoreFailures = 0;
|
||||
}
|
||||
|
||||
private static void showMissingPackMessage(Minecraft client) {
|
||||
if (!missingMessageShown && client.player != null) {
|
||||
client.gui.hud.getChat().addClientSystemMessage(
|
||||
Component.translatable("message.ambiance.alpha_textures_missing"));
|
||||
missingMessageShown = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void showRestoreFailureMessage(Minecraft client) {
|
||||
if (client.player != null) {
|
||||
client.gui.hud.getChat().addClientSystemMessage(
|
||||
Component.translatable("message.ambiance.alpha_textures_restore_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
record PackPair(String base, String alpha) {
|
||||
PackPair {
|
||||
if (base == null || alpha == null || base.equals(alpha)) {
|
||||
throw new IllegalArgumentException("Golden Days Base and Alpha packs must be distinct");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,71 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import fr.koka99cab.realtime.client.RealtimeClient;
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceEnvironmentPayload;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
|
||||
import net.fabricmc.fabric.api.client.render.fluid.v1.FluidRenderingRegistry;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.BlockColorRegistry;
|
||||
import net.minecraft.client.renderer.block.FluidModel;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.minecraft.client.resources.model.sprite.Material;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
import java.util.List;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
public final class AmbianceClient implements ClientModInitializer {
|
||||
private static final KeyMapping.Category AMBIANCE_CATEGORY =
|
||||
KeyMapping.Category.register(AmbianceMod.id("ambiance"));
|
||||
private static KeyMapping visualSettingsKey;
|
||||
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
RealtimeClient.initialize();
|
||||
AlphaTexturePackManager.register();
|
||||
CelestialSkyRenderer.register();
|
||||
ClientTickEvents.END_CLIENT_TICK.register(DynamicTorchLights::tick);
|
||||
ClientPlayNetworking.registerGlobalReceiver(AmbianceEnvironmentPayload.TYPE, (payload, context) ->
|
||||
context.client().execute(() -> AmbianceClientEnvironment.apply(payload.snapshot()))
|
||||
);
|
||||
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Normal.ID, (payload, context) ->
|
||||
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
|
||||
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.BadTrip.ID, (payload, context) ->
|
||||
context.client().execute(() -> AmbianceClientVisualEffects.apply(payload)));
|
||||
ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Clear.ID, (payload, context) ->
|
||||
context.client().execute(AmbianceClientVisualEffects::clear));
|
||||
ClientPlayNetworking.registerGlobalReceiver(WhiteDiscPlaybackPayload.ID, (payload, context) ->
|
||||
context.client().execute(() -> WhiteDiscAudioPlayer.play(
|
||||
context.client(), payload.pos(), payload.videoId())));
|
||||
visualSettingsKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||
"key.ambiance.visual_settings", InputConstants.Type.KEYSYM,
|
||||
GLFW.GLFW_KEY_UNKNOWN, AMBIANCE_CATEGORY));
|
||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||
AmbianceClientVisualEffects.tick();
|
||||
WhiteDiscAudioPlayer.tick(client);
|
||||
while (visualSettingsKey.consumeClick()) {
|
||||
if (client.gui.screen() == null) client.gui.setScreen(new AmbianceVisualSettingsScreen(null));
|
||||
}
|
||||
});
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
|
||||
AmbianceClientEnvironment.reset();
|
||||
DynamicTorchLights.reset();
|
||||
AmbianceClientVisualEffects.clear();
|
||||
WhiteDiscAudioPlayer.clear();
|
||||
});
|
||||
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown());
|
||||
AmbianceVisualOverlay.register();
|
||||
AmbianceBiomeTints.registerColorResolvers();
|
||||
registerWaterFluidTint();
|
||||
BlockColorRegistry.register(List.of(AmbianceBiomeTints.GRASS),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
|
||||
|
||||
/** Single normal plus single bad-trip layer, composed and expired by Ambiance. */
|
||||
public final class AmbianceClientVisualEffects {
|
||||
public enum Mode { NONE, NORMAL, BAD_TRIP }
|
||||
private static AmbianceVisualAccessibility accessibility = AmbianceVisualAccessibility.load();
|
||||
private static AmbianceVisualProfile normal = AmbianceVisualProfile.NONE;
|
||||
private static AmbianceVisualProfile badTrip = AmbianceVisualProfile.NONE;
|
||||
private static int normalTicks;
|
||||
private static int badTripTicks;
|
||||
|
||||
private AmbianceClientVisualEffects() {}
|
||||
|
||||
public static void apply(AmbianceCanapliaVisualPayloads.Normal payload) {
|
||||
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
|
||||
normal = payload.profile();
|
||||
normalTicks = payload.remainingTicks();
|
||||
}
|
||||
|
||||
public static void apply(AmbianceCanapliaVisualPayloads.BadTrip payload) {
|
||||
if (payload.protocol() != AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION) return;
|
||||
badTrip = payload.profile();
|
||||
badTripTicks = payload.remainingTicks();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
normal = AmbianceVisualProfile.NONE;
|
||||
badTrip = AmbianceVisualProfile.NONE;
|
||||
normalTicks = 0;
|
||||
badTripTicks = 0;
|
||||
}
|
||||
|
||||
public static void tick() {
|
||||
if (normalTicks > 0 && --normalTicks == 0) normal = AmbianceVisualProfile.NONE;
|
||||
if (badTripTicks > 0 && --badTripTicks == 0) badTrip = AmbianceVisualProfile.NONE;
|
||||
}
|
||||
|
||||
public static Mode mode() {
|
||||
return badTripTicks > 0 ? Mode.BAD_TRIP : normalTicks > 0 ? Mode.NORMAL : Mode.NONE;
|
||||
}
|
||||
|
||||
public static AmbianceVisualProfile current() {
|
||||
return accessible(mode() == Mode.BAD_TRIP ? badTrip
|
||||
: mode() == Mode.NORMAL ? normal : AmbianceVisualProfile.NONE);
|
||||
}
|
||||
|
||||
public static AmbianceVisualAccessibility accessibility() {
|
||||
return accessibility;
|
||||
}
|
||||
|
||||
public static void updateAccessibility(AmbianceVisualAccessibility updated) {
|
||||
accessibility = updated == null ? AmbianceVisualAccessibility.defaults() : updated;
|
||||
accessibility.save();
|
||||
}
|
||||
|
||||
private static AmbianceVisualProfile accessible(AmbianceVisualProfile profile) {
|
||||
float scale = accessibility.intensity();
|
||||
boolean noMotion = accessibility.reduceMotion();
|
||||
return new AmbianceVisualProfile(
|
||||
profile.intensity() * scale,
|
||||
profile.tintRed(), profile.tintGreen(), profile.tintBlue(),
|
||||
profile.tintStrength() * scale,
|
||||
profile.saturationDelta() * scale,
|
||||
profile.contrastDelta() * scale,
|
||||
profile.vignetteStrength() * scale,
|
||||
noMotion || !accessibility.ghostingEnabled() ? 0.0F : profile.ghostingStrength() * scale,
|
||||
noMotion || !accessibility.wobbleEnabled() ? 0.0F : profile.wobbleStrength() * scale,
|
||||
noMotion ? 0.0F : profile.pulseStrength() * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ public final class AmbianceEffects {
|
||||
Vector4f color = fogData.color;
|
||||
// Keep the deeper beta-like night fog without turning the horizon into a black wall.
|
||||
setAdjustedColor(color, 1.18F, Mth.lerp(night, 1.0F, 0.98F));
|
||||
applyTemporaryProfile(color);
|
||||
}
|
||||
|
||||
public static void adjustLightmap(LightmapRenderState state, float tickDelta) {
|
||||
@@ -75,6 +76,8 @@ public final class AmbianceEffects {
|
||||
state.blockLightTint = warmBlockLight(state.blockLightTint, night);
|
||||
state.blockFactor *= Mth.lerp(night, 1.0F, 1.08F);
|
||||
state.brightness = Math.min(state.brightness, Mth.lerp(night, 0.58F, 0.16F));
|
||||
state.ambientColor = applyTemporaryProfile(state.ambientColor);
|
||||
state.skyLightColor = applyTemporaryProfile(state.skyLightColor);
|
||||
}
|
||||
|
||||
private static void applyWhiteNightLight(LightmapRenderState state, float night) {
|
||||
@@ -128,4 +131,23 @@ public final class AmbianceEffects {
|
||||
Mth.lerp(warmth, saturated.z(), 0.56F)
|
||||
);
|
||||
}
|
||||
|
||||
private static void applyTemporaryProfile(Vector4f color) {
|
||||
Vector3f adjusted = applyTemporaryProfile(new Vector3f(color.x(), color.y(), color.z()));
|
||||
color.set(adjusted.x(), adjusted.y(), adjusted.z(), color.w());
|
||||
}
|
||||
|
||||
private static Vector3f applyTemporaryProfile(Vector3fc source) {
|
||||
var profile = AmbianceClientVisualEffects.current();
|
||||
float intensity = profile.intensity();
|
||||
if (intensity <= 0.0F) return new Vector3f(source);
|
||||
Vector3f graded = adjustedColor(source, 1.0F + profile.saturationDelta() * intensity,
|
||||
1.0F + profile.contrastDelta() * intensity * 0.35F);
|
||||
float tint = profile.tintStrength() * intensity * 0.28F;
|
||||
return new Vector3f(
|
||||
Mth.lerp(tint, graded.x(), profile.tintRed()),
|
||||
Mth.lerp(tint, graded.y(), profile.tintGreen()),
|
||||
Mth.lerp(tint, graded.z(), profile.tintBlue())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
/** Local presentation-only settings; they can never affect server gameplay state. */
|
||||
public record AmbianceVisualAccessibility(float intensity, boolean reduceMotion,
|
||||
boolean wobbleEnabled, boolean ghostingEnabled) {
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
public AmbianceVisualAccessibility {
|
||||
intensity = Float.isFinite(intensity) ? Math.clamp(intensity, 0.0F, 1.0F) : 1.0F;
|
||||
}
|
||||
|
||||
public static AmbianceVisualAccessibility load() {
|
||||
try {
|
||||
Path file = configPath();
|
||||
if (!Files.isRegularFile(file)) return defaults();
|
||||
JsonObject json = GSON.fromJson(Files.readString(file), JsonObject.class);
|
||||
if (json == null) return defaults();
|
||||
return new AmbianceVisualAccessibility(
|
||||
json.has("intensity") ? json.get("intensity").getAsFloat() : 1.0F,
|
||||
json.has("reduce_motion") && json.get("reduce_motion").getAsBoolean(),
|
||||
!json.has("wobble") || json.get("wobble").getAsBoolean(),
|
||||
!json.has("ghosting") || json.get("ghosting").getAsBoolean());
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
return defaults();
|
||||
}
|
||||
}
|
||||
|
||||
public void save() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("intensity", intensity);
|
||||
json.addProperty("reduce_motion", reduceMotion);
|
||||
json.addProperty("wobble", wobbleEnabled);
|
||||
json.addProperty("ghosting", ghostingEnabled);
|
||||
try {
|
||||
Path file = configPath();
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, GSON.toJson(json) + System.lineSeparator());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
AmbianceMod.LOGGER.warn("[Ambiance] Could not save visual accessibility settings.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static AmbianceVisualAccessibility defaults() {
|
||||
return new AmbianceVisualAccessibility(1.0F, false, true, true);
|
||||
}
|
||||
|
||||
private static Path configPath() {
|
||||
return FabricLoader.getInstance().getConfigDir().resolve("ambiance-visual.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
|
||||
/** Lightweight final-frame tint/vignette; no competing post-processing owner is introduced. */
|
||||
public final class AmbianceVisualOverlay {
|
||||
private AmbianceVisualOverlay() {}
|
||||
|
||||
public static void register() {
|
||||
HudElementRegistry.addLast(AmbianceMod.id("temporary_visual_effects"), AmbianceVisualOverlay::render);
|
||||
}
|
||||
|
||||
private static void render(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker) {
|
||||
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
|
||||
if (profile.intensity() <= 0.0F) return;
|
||||
int width = graphics.guiWidth();
|
||||
int height = graphics.guiHeight();
|
||||
int alpha = Math.clamp(Math.round(255.0F * profile.intensity() * profile.tintStrength() * 0.32F), 0, 80);
|
||||
int rgb = (Math.round(profile.tintRed() * 255.0F) << 16)
|
||||
| (Math.round(profile.tintGreen() * 255.0F) << 8)
|
||||
| Math.round(profile.tintBlue() * 255.0F);
|
||||
graphics.fill(0, 0, width, height, (alpha << 24) | rgb);
|
||||
int depth = Math.max(8, Math.min(width, height) / 12);
|
||||
int edgeAlpha = Math.clamp(Math.round(190.0F * profile.vignetteStrength() * profile.intensity()), 0, 150);
|
||||
for (int layer = 0; layer < 4; layer++) {
|
||||
int outer = layer * depth / 4;
|
||||
int inner = (layer + 1) * depth / 4;
|
||||
int color = ((edgeAlpha * (4 - layer) / 4) << 24) | (rgb & 0x7F7F7F);
|
||||
graphics.fill(outer, outer, width - outer, inner, color);
|
||||
graphics.fill(outer, height - inner, width - outer, height - outer, color);
|
||||
graphics.fill(outer, inner, inner, height - inner, color);
|
||||
graphics.fill(width - inner, inner, width - outer, height - inner, color);
|
||||
}
|
||||
if (profile.ghostingStrength() > 0.0F) {
|
||||
int ghostAlpha = Math.clamp(Math.round(profile.ghostingStrength() * 45.0F), 0, 45);
|
||||
graphics.fill(2, 0, 5, height, (ghostAlpha << 24) | rgb);
|
||||
graphics.fill(width - 7, 0, width - 3, height, (ghostAlpha << 24) | rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Small in-game editor for the local presentation-only Canaplia visual settings. */
|
||||
public final class AmbianceVisualSettingsScreen extends Screen {
|
||||
private final Screen parent;
|
||||
|
||||
public AmbianceVisualSettingsScreen(Screen parent) {
|
||||
super(Component.translatable("options.ambiance.visual.title"));
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
AmbianceVisualAccessibility settings = AmbianceClientVisualEffects.accessibility();
|
||||
int left = width / 2 - 120;
|
||||
int y = height / 2 - 62;
|
||||
addRenderableWidget(Button.builder(value("options.ambiance.visual.intensity",
|
||||
Math.round(settings.intensity() * 100.0F) + "%"), button -> {
|
||||
float next = settings.intensity() <= 0.0F ? 1.0F : settings.intensity() - 0.25F;
|
||||
update(new AmbianceVisualAccessibility(next, settings.reduceMotion(),
|
||||
settings.wobbleEnabled(), settings.ghostingEnabled()));
|
||||
}).bounds(left, y, 240, 20).build());
|
||||
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.reduce_motion", settings.reduceMotion()),
|
||||
button -> update(new AmbianceVisualAccessibility(settings.intensity(), !settings.reduceMotion(),
|
||||
settings.wobbleEnabled(), settings.ghostingEnabled())))
|
||||
.bounds(left, y + 24, 240, 20).build());
|
||||
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.wobble", settings.wobbleEnabled()),
|
||||
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
|
||||
!settings.wobbleEnabled(), settings.ghostingEnabled())))
|
||||
.bounds(left, y + 48, 240, 20).build());
|
||||
addRenderableWidget(Button.builder(toggle("options.ambiance.visual.ghosting", settings.ghostingEnabled()),
|
||||
button -> update(new AmbianceVisualAccessibility(settings.intensity(), settings.reduceMotion(),
|
||||
settings.wobbleEnabled(), !settings.ghostingEnabled())))
|
||||
.bounds(left, y + 72, 240, 20).build());
|
||||
addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> onClose())
|
||||
.bounds(left + 44, y + 108, 152, 20).build());
|
||||
}
|
||||
|
||||
private void update(AmbianceVisualAccessibility settings) {
|
||||
AmbianceClientVisualEffects.updateAccessibility(settings);
|
||||
rebuildWidgets();
|
||||
}
|
||||
|
||||
private static Component toggle(String key, boolean enabled) {
|
||||
return value(key, Component.translatable(enabled ? "options.on" : "options.off"));
|
||||
}
|
||||
|
||||
private static Component value(String key, String value) {
|
||||
return value(key, Component.literal(value));
|
||||
}
|
||||
|
||||
private static Component value(String key, Component value) {
|
||||
return Component.translatable(key).append(": ").append(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
|
||||
graphics.fill(width / 2 - 142, height / 2 - 92, width / 2 + 142, height / 2 + 92, 0xE0161C19);
|
||||
graphics.centeredText(font, title, width / 2, height / 2 - 82, 0xFFE4EAD9);
|
||||
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
if (minecraft != null) minecraft.gui.setScreen(parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import net.minecraft.client.sounds.AudioStream;
|
||||
|
||||
/** Downmixes bounded stereo PCM frames from Lavaplayer for Minecraft's mono OpenAL stream. */
|
||||
final class LavaplayerAudioStream implements AudioStream {
|
||||
static final int SAMPLE_RATE = 48_000;
|
||||
static final int SOURCE_CHANNELS = 2;
|
||||
static final int FRAME_SAMPLES = 960;
|
||||
private static final int BYTES_PER_SAMPLE = Short.BYTES;
|
||||
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
|
||||
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
|
||||
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
|
||||
private static final AudioFormat FORMAT =
|
||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false);
|
||||
private final AudioPlayer player;
|
||||
private byte[] pending = new byte[0];
|
||||
private int pendingOffset;
|
||||
private long decodedBytes;
|
||||
private long paddedSilenceBytes;
|
||||
private long firstFrameTimecode = -1L;
|
||||
private long lastFrameTimecode = -1L;
|
||||
private int decodedFrames;
|
||||
private boolean initialWaitAttempted;
|
||||
private volatile boolean closed;
|
||||
private volatile boolean ended;
|
||||
|
||||
LavaplayerAudioStream(AudioPlayer player) {
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioFormat getFormat() {
|
||||
return FORMAT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ByteBuffer read(int maximumBytes) throws IOException {
|
||||
if (closed || maximumBytes <= 0) return null;
|
||||
ByteBuffer output = ByteBuffer.allocateDirect(maximumBytes);
|
||||
while (output.hasRemaining() && !closed) {
|
||||
if (pendingOffset < pending.length) {
|
||||
int length = Math.min(output.remaining(), pending.length - pendingOffset);
|
||||
output.put(pending, pendingOffset, length);
|
||||
pendingOffset += length;
|
||||
decodedBytes += length;
|
||||
continue;
|
||||
}
|
||||
AudioFrame frame = nextFrame();
|
||||
if (frame == null) {
|
||||
if (ended) break;
|
||||
while (output.hasRemaining()) {
|
||||
output.put((byte) 0);
|
||||
paddedSilenceBytes++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (frame.isTerminator()) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
|
||||
lastFrameTimecode = frame.getTimecode();
|
||||
decodedFrames++;
|
||||
pending = downmixStereoFrame(frame.getData());
|
||||
pendingOffset = 0;
|
||||
}
|
||||
if (output.position() == 0 && ended) return null;
|
||||
output.flip();
|
||||
return output;
|
||||
}
|
||||
|
||||
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
|
||||
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
|
||||
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
|
||||
}
|
||||
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
|
||||
for (int sourceOffset = 0, targetOffset = 0;
|
||||
sourceOffset < stereo.length;
|
||||
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
|
||||
short left = littleEndianSample(stereo, sourceOffset);
|
||||
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
|
||||
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
|
||||
mono[targetOffset] = (byte) mixed;
|
||||
mono[targetOffset + 1] = (byte) (mixed >>> 8);
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
private static short littleEndianSample(byte[] pcm, int offset) {
|
||||
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
|
||||
}
|
||||
|
||||
private AudioFrame nextFrame() throws IOException {
|
||||
try {
|
||||
long waitMillis = initialWaitAttempted
|
||||
? CONTINUATION_FRAME_WAIT_MILLIS
|
||||
: INITIAL_FRAME_WAIT_MILLIS;
|
||||
initialWaitAttempted = true;
|
||||
AudioFrame frame = player.provide(waitMillis, TimeUnit.MILLISECONDS);
|
||||
if (frame == null && player.getPlayingTrack() == null) ended = true;
|
||||
return frame;
|
||||
} catch (TimeoutException ignored) {
|
||||
return null;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while buffering the white disc", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
boolean ended() {
|
||||
return ended;
|
||||
}
|
||||
|
||||
boolean uses(AudioPlayer expectedPlayer) {
|
||||
return player == expectedPlayer;
|
||||
}
|
||||
|
||||
long decodedBytes() {
|
||||
return decodedBytes;
|
||||
}
|
||||
|
||||
long paddedSilenceBytes() {
|
||||
return paddedSilenceBytes;
|
||||
}
|
||||
|
||||
long decodedTimelineMillis() {
|
||||
if (decodedFrames == 0) return 0L;
|
||||
return lastFrameTimecode - firstFrameTimecode + 20L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.mojang.blaze3d.audio.Library;
|
||||
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import dev.lavalink.youtube.clients.Ios;
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor;
|
||||
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor;
|
||||
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.sounds.ChannelAccess;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.JukeboxBlock;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Client-only, positional and resource-bounded YouTube playback for white discs. */
|
||||
public final class WhiteDiscAudioPlayer {
|
||||
private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4;
|
||||
private static final int MAXIMUM_START_WAIT_TICKS = 100;
|
||||
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
|
||||
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
|
||||
private static final float ATTENUATION_DISTANCE = 64.0F;
|
||||
/** OpenAL must preserve the decoded music's native speed and pitch. */
|
||||
static final float PLAYBACK_PITCH = 1.0F;
|
||||
private static final DefaultAudioPlayerManager MANAGER = createManager();
|
||||
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
|
||||
private static final Map<Long, Long> REQUESTS = new HashMap<>();
|
||||
private static final LinkedHashMap<Long, Playback> PLAYBACKS = new LinkedHashMap<>();
|
||||
private static long requestSequence;
|
||||
private static boolean shutdown;
|
||||
|
||||
private WhiteDiscAudioPlayer() {
|
||||
}
|
||||
|
||||
public static void play(Minecraft client, BlockPos pos, String videoId) {
|
||||
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validJukeboxTarget(client, pos)) return;
|
||||
long key = pos.asLong();
|
||||
if (PENDING_STARTS.containsKey(key) || REQUESTS.containsKey(key)) return;
|
||||
boolean replacingPlayback = PLAYBACKS.containsKey(key);
|
||||
if (!replacingPlayback
|
||||
&& PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()
|
||||
>= MAXIMUM_SIMULTANEOUS_DISCS) return;
|
||||
stop(key);
|
||||
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
|
||||
if (!validTarget(client, pos)) {
|
||||
PENDING_STARTS.put(key, new PendingStart(pos.immutable(), client.level.dimension(), videoId,
|
||||
MAXIMUM_START_WAIT_TICKS));
|
||||
return;
|
||||
}
|
||||
startLoading(client, pos, videoId);
|
||||
}
|
||||
|
||||
private static void startLoading(Minecraft client, BlockPos pos, String videoId) {
|
||||
long key = pos.asLong();
|
||||
long request = ++requestSequence;
|
||||
REQUESTS.put(key, request);
|
||||
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track) {
|
||||
client.execute(() -> acceptTrack(client, pos, request, track));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playlistLoaded(AudioPlaylist playlist) {
|
||||
AudioTrack selected = playlist.getSelectedTrack();
|
||||
if (selected == null && !playlist.getTracks().isEmpty()) selected = playlist.getTracks().getFirst();
|
||||
if (selected == null) noMatches();
|
||||
else trackLoaded(selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches() {
|
||||
client.execute(() -> fail(client, key, request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(FriendlyException exception) {
|
||||
AmbianceMod.LOGGER.warn("Unable to load white-disc YouTube video {}: {}", videoId,
|
||||
exception.getMessage());
|
||||
client.execute(() -> fail(client, key, request));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void acceptTrack(Minecraft client, BlockPos pos, long request, AudioTrack track) {
|
||||
long key = pos.asLong();
|
||||
if (REQUESTS.getOrDefault(key, -1L) != request) return;
|
||||
if (!validTarget(client, pos)) {
|
||||
REQUESTS.remove(key);
|
||||
return;
|
||||
}
|
||||
if (track.getInfo().isStream || track.getDuration() <= 0L
|
||||
|| track.getDuration() > MAXIMUM_TRACK_MILLIS) {
|
||||
REQUESTS.remove(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.too_long"));
|
||||
return;
|
||||
}
|
||||
while (PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) {
|
||||
stop(PLAYBACKS.keySet().iterator().next());
|
||||
}
|
||||
ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager())
|
||||
.ambiance$getSoundEngine()).ambiance$getChannelAccess();
|
||||
channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> {
|
||||
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request
|
||||
|| !validTarget(client, pos)) {
|
||||
if (handle != null) handle.execute(channel -> channel.stop());
|
||||
if (REQUESTS.getOrDefault(key, -1L) == request) REQUESTS.remove(key);
|
||||
return;
|
||||
}
|
||||
AudioPlayer player = MANAGER.createPlayer();
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream);
|
||||
PLAYBACKS.put(key, playback);
|
||||
REQUESTS.remove(key);
|
||||
player.addListener(new AudioEventAdapter() {
|
||||
@Override
|
||||
public void onTrackException(AudioPlayer failedPlayer, AudioTrack failedTrack,
|
||||
FriendlyException exception) {
|
||||
AmbianceMod.LOGGER.warn("White-disc playback failed for YouTube video {}: {}",
|
||||
failedTrack.getIdentifier(), exception.getMessage());
|
||||
client.execute(() -> failPlayback(client, key, failedPlayer));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrackStuck(AudioPlayer stuckPlayer, AudioTrack stuckTrack,
|
||||
long thresholdMs) {
|
||||
AmbianceMod.LOGGER.warn("White-disc playback stalled for YouTube video {} after {} ms",
|
||||
stuckTrack.getIdentifier(), thresholdMs);
|
||||
client.execute(() -> failPlayback(client, key, stuckPlayer));
|
||||
}
|
||||
});
|
||||
player.playTrack(track);
|
||||
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
|
||||
handle.execute(channel -> {
|
||||
try {
|
||||
channel.setSelfPosition(Vec3.atCenterOf(pos));
|
||||
channel.setRelative(false);
|
||||
channel.setPitch(PLAYBACK_PITCH);
|
||||
channel.linearAttenuation(ATTENUATION_DISTANCE);
|
||||
channel.setVolume(volume);
|
||||
channel.attachBufferStream(stream);
|
||||
channel.play();
|
||||
} catch (RuntimeException | LinkageError exception) {
|
||||
AmbianceMod.LOGGER.error("Unable to attach white-disc audio to Minecraft's sound engine",
|
||||
exception);
|
||||
client.execute(() -> failPlayback(client, key, player));
|
||||
}
|
||||
});
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title));
|
||||
}));
|
||||
}
|
||||
|
||||
private static void fail(Minecraft client, long key, long request) {
|
||||
if (REQUESTS.getOrDefault(key, -1L) != request) return;
|
||||
REQUESTS.remove(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
}
|
||||
|
||||
private static void failPlayback(Minecraft client, long key, AudioPlayer player) {
|
||||
Playback playback = PLAYBACKS.get(key);
|
||||
if (playback == null || !playback.stream().uses(player)) return;
|
||||
stop(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
}
|
||||
|
||||
public static void stop(BlockPos pos) {
|
||||
stop(pos.asLong());
|
||||
}
|
||||
|
||||
private static void stop(long key) {
|
||||
PENDING_STARTS.remove(key);
|
||||
REQUESTS.remove(key);
|
||||
Playback playback = PLAYBACKS.remove(key);
|
||||
if (playback == null) return;
|
||||
playback.stream().close();
|
||||
playback.handle().execute(channel -> channel.stop());
|
||||
}
|
||||
|
||||
public static void tick(Minecraft client) {
|
||||
if (client.level == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
tickPendingStarts(client);
|
||||
long now = System.nanoTime();
|
||||
for (Map.Entry<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
|
||||
Playback playback = entry.getValue();
|
||||
boolean missing = !client.level.dimension().equals(playback.dimension())
|
||||
|| !client.level.hasChunkAt(playback.pos())
|
||||
|| !client.level.getBlockState(playback.pos()).is(Blocks.JUKEBOX)
|
||||
|| !client.level.getBlockState(playback.pos()).getValue(JukeboxBlock.HAS_RECORD);
|
||||
if (missing || playback.finishedFor(now) >= FINISHED_GRACE_NANOS) {
|
||||
stop(entry.getKey());
|
||||
continue;
|
||||
}
|
||||
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
|
||||
playback.handle().execute(channel -> {
|
||||
channel.setPitch(PLAYBACK_PITCH);
|
||||
channel.setVolume(volume);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
PENDING_STARTS.clear();
|
||||
REQUESTS.clear();
|
||||
for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key);
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
if (shutdown) return;
|
||||
shutdown = true;
|
||||
clear();
|
||||
MANAGER.shutdown();
|
||||
}
|
||||
|
||||
private static DefaultAudioPlayerManager createManager() {
|
||||
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
|
||||
manager.getConfiguration().setOutputFormat(
|
||||
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
|
||||
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
|
||||
manager.setFrameBufferDuration(5_000);
|
||||
manager.setItemLoaderThreadPoolSize(2);
|
||||
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static boolean validTarget(Minecraft client, BlockPos pos) {
|
||||
return validJukeboxTarget(client, pos)
|
||||
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
|
||||
}
|
||||
|
||||
private static boolean validJukeboxTarget(Minecraft client, BlockPos pos) {
|
||||
return client.player != null && client.level != null
|
||||
&& client.player.distanceToSqr(Vec3.atCenterOf(pos))
|
||||
<= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D
|
||||
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX);
|
||||
}
|
||||
|
||||
private static void tickPendingStarts(Minecraft client) {
|
||||
for (Map.Entry<Long, PendingStart> entry : Map.copyOf(PENDING_STARTS).entrySet()) {
|
||||
long key = entry.getKey();
|
||||
PendingStart pending = entry.getValue();
|
||||
if (!client.level.dimension().equals(pending.dimension())
|
||||
|| !validJukeboxTarget(client, pending.pos())) {
|
||||
PENDING_STARTS.remove(key);
|
||||
continue;
|
||||
}
|
||||
if (validTarget(client, pending.pos())) {
|
||||
PENDING_STARTS.remove(key);
|
||||
startLoading(client, pending.pos(), pending.videoId());
|
||||
continue;
|
||||
}
|
||||
if (pending.ticksRemaining() <= 1) {
|
||||
PENDING_STARTS.remove(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
continue;
|
||||
}
|
||||
PENDING_STARTS.put(key, pending.waitOneTick());
|
||||
}
|
||||
}
|
||||
|
||||
private record PendingStart(BlockPos pos, ResourceKey<Level> dimension, String videoId,
|
||||
int ticksRemaining) {
|
||||
private PendingStart waitOneTick() {
|
||||
return new PendingStart(pos, dimension, videoId, ticksRemaining - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private record Playback(BlockPos pos, ResourceKey<Level> dimension,
|
||||
ChannelAccess.ChannelHandle handle,
|
||||
LavaplayerAudioStream stream, long[] finishedAt) {
|
||||
private Playback(BlockPos pos, ResourceKey<Level> dimension,
|
||||
ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) {
|
||||
this(pos, dimension, handle, stream, new long[1]);
|
||||
}
|
||||
|
||||
private long finishedFor(long now) {
|
||||
if (!stream.ended()) return 0L;
|
||||
if (finishedAt[0] == 0L) finishedAt[0] = now;
|
||||
return now - finishedAt[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.network.protocol.game.ClientboundGameEventPacket;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.saveddata.WeatherData;
|
||||
|
||||
@@ -22,6 +24,8 @@ import net.minecraft.world.level.saveddata.WeatherData;
|
||||
*/
|
||||
public final class AmbianceEnvironmentController {
|
||||
private static final int UPDATE_INTERVAL_TICKS = 20;
|
||||
/** Vanilla fades rain and thunder by 0.01 per tick; stay above that so we do not snap. */
|
||||
private static final float WEATHER_FADE_EPSILON = 0.012F;
|
||||
private static final Map<MinecraftServer, TimedWeather> TIMED_WEATHER = new IdentityHashMap<>();
|
||||
private static final Map<MinecraftServer, AmbianceLunarNight> LUNAR_OVERRIDES = new IdentityHashMap<>();
|
||||
private static final Map<MinecraftServer, String> LAST_BROADCAST_WEATHER = new IdentityHashMap<>();
|
||||
@@ -44,6 +48,8 @@ public final class AmbianceEnvironmentController {
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||
if (server.getTickCount() % UPDATE_INTERVAL_TICKS == 0) {
|
||||
refresh(server);
|
||||
} else {
|
||||
applyWeather(server, effectiveWeather(server, AmbianceData.get(server)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -210,6 +216,9 @@ public final class AmbianceEnvironmentController {
|
||||
}
|
||||
|
||||
private static void applyWeather(MinecraftServer server, AmbianceDailyWeather weather) {
|
||||
float targetRain = weather.rainIntensity();
|
||||
float targetThunder = weather.thunder() ? 1.0F : 0.0F;
|
||||
PlayerList players = server.getPlayerList();
|
||||
for (ServerLevel level : server.getAllLevels()) {
|
||||
if (!level.canHaveWeather()) {
|
||||
continue;
|
||||
@@ -217,11 +226,37 @@ public final class AmbianceEnvironmentController {
|
||||
WeatherData data = level.getWeatherData();
|
||||
data.setClearWeatherTime(weather.raining() ? 0 : 2400);
|
||||
data.setRainTime(weather.raining() ? 2400 : 0);
|
||||
data.setRaining(weather.raining());
|
||||
data.setThunderTime(weather.thunder() ? 2400 : 0);
|
||||
data.setThundering(weather.thunder());
|
||||
level.setRainLevel(weather.rainIntensity());
|
||||
level.setThunderLevel(weather.thunder() ? 1.0F : 0.0F);
|
||||
float currentRain = level.getRainLevel(1.0F);
|
||||
float currentThunder = level.getThunderLevel(1.0F);
|
||||
if (currentRain > targetRain + WEATHER_FADE_EPSILON) {
|
||||
data.setRaining(false);
|
||||
} else if (currentRain < targetRain - WEATHER_FADE_EPSILON) {
|
||||
data.setRaining(true);
|
||||
} else {
|
||||
data.setRaining(weather.raining());
|
||||
if (currentRain != targetRain) {
|
||||
level.setRainLevel(weather.rainIntensity());
|
||||
players.broadcastAll(
|
||||
new ClientboundGameEventPacket(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, targetRain),
|
||||
level.dimension()
|
||||
);
|
||||
}
|
||||
}
|
||||
if (currentThunder > targetThunder + WEATHER_FADE_EPSILON) {
|
||||
data.setThundering(false);
|
||||
} else if (currentThunder < targetThunder - WEATHER_FADE_EPSILON) {
|
||||
data.setThundering(true);
|
||||
} else {
|
||||
data.setThundering(weather.thunder());
|
||||
if (currentThunder != targetThunder) {
|
||||
level.setThunderLevel(targetThunder);
|
||||
players.broadcastAll(
|
||||
new ClientboundGameEventPacket(ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, targetThunder),
|
||||
level.dimension()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.item;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
|
||||
/** Blank disc whose custom name may hold a validated YouTube URL. */
|
||||
public final class WhiteDiscItem extends Item {
|
||||
public WhiteDiscItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
tooltip.accept(Component.translatable("tooltip.ambiance.white_disc")
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Bounded motion is applied on the central Ambiance camera path and disappears with the profile. */
|
||||
@Mixin(Camera.class)
|
||||
public abstract class CameraMixin {
|
||||
@Shadow public abstract float yaw();
|
||||
@Shadow public abstract float xRot();
|
||||
@Shadow protected abstract void setRotation(float yaw, float pitch);
|
||||
|
||||
@Inject(method = "update", at = @At("TAIL"))
|
||||
private void ambiance$applyTemporaryMotion(DeltaTracker deltaTracker, CallbackInfo ci) {
|
||||
AmbianceVisualProfile profile = AmbianceClientVisualEffects.current();
|
||||
if (profile.wobbleStrength() <= 0.0F && profile.pulseStrength() <= 0.0F) return;
|
||||
double time = System.nanoTime() / 1_000_000_000.0D;
|
||||
float yawOffset = (float) Math.sin(time * 2.1D) * profile.wobbleStrength() * 1.8F;
|
||||
float pitchOffset = (float) Math.cos(time * 1.7D) *
|
||||
(profile.wobbleStrength() + profile.pulseStrength() * 0.5F) * 1.2F;
|
||||
setRotation(yaw() + yawOffset, xRot() + pitchOffset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
|
||||
import net.minecraft.client.renderer.LevelEventHandler;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(LevelEventHandler.class)
|
||||
public abstract class LevelEventHandlerMixin {
|
||||
@Inject(method = "levelEvent", at = @At("HEAD"))
|
||||
private void ambiance$stopWhiteDisc(int type, BlockPos pos, int data, CallbackInfo callback) {
|
||||
if (type == 1011) WhiteDiscAudioPlayer.stop(pos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
|
||||
|
||||
import net.minecraft.client.sounds.ChannelAccess;
|
||||
import net.minecraft.client.sounds.SoundEngine;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(SoundEngine.class)
|
||||
public interface SoundEngineAccessor {
|
||||
@Accessor("channelAccess")
|
||||
ChannelAccess ambiance$getChannelAccess();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
|
||||
|
||||
import net.minecraft.client.sounds.SoundEngine;
|
||||
import net.minecraft.client.sounds.SoundManager;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
@Mixin(SoundManager.class)
|
||||
public interface SoundManagerAccessor {
|
||||
@Accessor("soundEngine")
|
||||
SoundEngine ambiance$getSoundEngine();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.network;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualEffectApi;
|
||||
import fr.koka99cab.sanctuary26.ambiance.api.visual.AmbianceVisualProfile;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Ambiance-owned, versioned implementation of the three historical Canaplia payload IDs. */
|
||||
public final class AmbianceCanapliaVisualPayloads {
|
||||
public static final int PROTOCOL_VERSION = 1;
|
||||
private static final Identifier NORMAL_ID = id("hemp_visual_sync");
|
||||
private static final Identifier BAD_TRIP_ID = id("hemp_bad_trip");
|
||||
private static final Identifier CLEAR_ID = id("hemp_visual_clear");
|
||||
|
||||
private AmbianceCanapliaVisualPayloads() {}
|
||||
|
||||
public record Normal(int protocol, AmbianceVisualProfile profile, int remainingTicks)
|
||||
implements CustomPacketPayload {
|
||||
public static final Type<Normal> ID = new Type<>(NORMAL_ID);
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, Normal> CODEC =
|
||||
CustomPacketPayload.codec(Normal::write, Normal::new);
|
||||
|
||||
public Normal {
|
||||
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
|
||||
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(NORMAL_ID, profile, remainingTicks);
|
||||
profile = request.profile();
|
||||
remainingTicks = request.remainingTicks();
|
||||
}
|
||||
|
||||
private Normal(RegistryFriendlyByteBuf buffer) {
|
||||
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
|
||||
}
|
||||
|
||||
private void write(RegistryFriendlyByteBuf buffer) {
|
||||
buffer.writeVarInt(protocol);
|
||||
writeProfile(buffer, profile);
|
||||
buffer.writeVarInt(remainingTicks);
|
||||
}
|
||||
|
||||
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
|
||||
}
|
||||
|
||||
public record BadTrip(int protocol, AmbianceVisualProfile profile, int remainingTicks)
|
||||
implements CustomPacketPayload {
|
||||
public static final Type<BadTrip> ID = new Type<>(BAD_TRIP_ID);
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, BadTrip> CODEC =
|
||||
CustomPacketPayload.codec(BadTrip::write, BadTrip::new);
|
||||
|
||||
public BadTrip {
|
||||
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
|
||||
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(BAD_TRIP_ID, profile, remainingTicks);
|
||||
profile = request.profile();
|
||||
remainingTicks = request.remainingTicks();
|
||||
}
|
||||
|
||||
private BadTrip(RegistryFriendlyByteBuf buffer) {
|
||||
this(buffer.readVarInt(), readProfile(buffer), buffer.readVarInt());
|
||||
}
|
||||
|
||||
private void write(RegistryFriendlyByteBuf buffer) {
|
||||
buffer.writeVarInt(protocol);
|
||||
writeProfile(buffer, profile);
|
||||
buffer.writeVarInt(remainingTicks);
|
||||
}
|
||||
|
||||
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
|
||||
}
|
||||
|
||||
public record Clear(int protocol) implements CustomPacketPayload {
|
||||
public static final Type<Clear> ID = new Type<>(CLEAR_ID);
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, Clear> CODEC =
|
||||
CustomPacketPayload.codec(Clear::write, Clear::new);
|
||||
|
||||
public Clear {
|
||||
protocol = Math.clamp(protocol, 0, PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
private Clear(RegistryFriendlyByteBuf buffer) { this(buffer.readVarInt()); }
|
||||
private void write(RegistryFriendlyByteBuf buffer) { buffer.writeVarInt(protocol); }
|
||||
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
|
||||
}
|
||||
|
||||
private static AmbianceVisualProfile readProfile(RegistryFriendlyByteBuf buffer) {
|
||||
return new AmbianceVisualProfile(buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
|
||||
buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(), buffer.readFloat(),
|
||||
buffer.readFloat(), buffer.readFloat(), buffer.readFloat());
|
||||
}
|
||||
|
||||
private static void writeProfile(RegistryFriendlyByteBuf buffer, AmbianceVisualProfile profile) {
|
||||
buffer.writeFloat(profile.intensity());
|
||||
buffer.writeFloat(profile.tintRed());
|
||||
buffer.writeFloat(profile.tintGreen());
|
||||
buffer.writeFloat(profile.tintBlue());
|
||||
buffer.writeFloat(profile.tintStrength());
|
||||
buffer.writeFloat(profile.saturationDelta());
|
||||
buffer.writeFloat(profile.contrastDelta());
|
||||
buffer.writeFloat(profile.vignetteStrength());
|
||||
buffer.writeFloat(profile.ghostingStrength());
|
||||
buffer.writeFloat(profile.wobbleStrength());
|
||||
buffer.writeFloat(profile.pulseStrength());
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath("canaplia", path);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import net.minecraft.server.level.ServerPlayer;
|
||||
* Networking contract for Ambiance. Payload IDs must use the module namespace.
|
||||
*/
|
||||
public final class AmbianceNetworking {
|
||||
public static final int PROTOCOL_VERSION = 4;
|
||||
public static final int PROTOCOL_VERSION = 6;
|
||||
private static boolean initialized;
|
||||
|
||||
private AmbianceNetworking() {
|
||||
@@ -28,6 +28,14 @@ public final class AmbianceNetworking {
|
||||
AmbianceEnvironmentPayload.TYPE,
|
||||
AmbianceEnvironmentPayload.CODEC
|
||||
);
|
||||
PayloadTypeRegistry.clientboundPlay().register(
|
||||
AmbianceCanapliaVisualPayloads.Normal.ID, AmbianceCanapliaVisualPayloads.Normal.CODEC);
|
||||
PayloadTypeRegistry.clientboundPlay().register(
|
||||
AmbianceCanapliaVisualPayloads.BadTrip.ID, AmbianceCanapliaVisualPayloads.BadTrip.CODEC);
|
||||
PayloadTypeRegistry.clientboundPlay().register(
|
||||
AmbianceCanapliaVisualPayloads.Clear.ID, AmbianceCanapliaVisualPayloads.Clear.CODEC);
|
||||
PayloadTypeRegistry.clientboundPlay().register(
|
||||
WhiteDiscPlaybackPayload.ID, WhiteDiscPlaybackPayload.CODEC);
|
||||
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
|
||||
send(handler.player, AmbianceEnvironmentController.snapshot(server))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.network;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
|
||||
/** Bounded clientbound intent: a server-resolved jukebox position and an 11-character video ID. */
|
||||
public record WhiteDiscPlaybackPayload(BlockPos pos, String videoId) implements CustomPacketPayload {
|
||||
public static final Type<WhiteDiscPlaybackPayload> ID =
|
||||
new Type<>(AmbianceMod.id("white_disc_playback"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, WhiteDiscPlaybackPayload> CODEC =
|
||||
CustomPacketPayload.codec(WhiteDiscPlaybackPayload::write, WhiteDiscPlaybackPayload::new);
|
||||
|
||||
public WhiteDiscPlaybackPayload {
|
||||
if (!WhiteDiscUrl.validVideoId(videoId)) videoId = "";
|
||||
}
|
||||
|
||||
private WhiteDiscPlaybackPayload(RegistryFriendlyByteBuf buffer) {
|
||||
this(buffer.readBlockPos(), buffer.readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH));
|
||||
}
|
||||
|
||||
private void write(RegistryFriendlyByteBuf buffer) {
|
||||
buffer.writeBlockPos(pos);
|
||||
buffer.writeUtf(videoId, WhiteDiscUrl.VIDEO_ID_LENGTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type<? extends CustomPacketPayload> type() {
|
||||
return ID;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.item.WhiteDiscItem;
|
||||
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.item.CreativeModeTab;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.JukeboxSong;
|
||||
import net.minecraft.world.item.Rarity;
|
||||
|
||||
/**
|
||||
* Single registration entry point for the Ambiance namespace.
|
||||
* Feature registries must be called from here to keep initialization ordering explicit.
|
||||
*/
|
||||
public final class AmbianceRegistries {
|
||||
public static final ResourceKey<JukeboxSong> WHITE_DISC_SONG = ResourceKey.create(
|
||||
Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc"));
|
||||
private static final ResourceKey<Item> WHITE_DISC_KEY = ResourceKey.create(
|
||||
Registries.ITEM, AmbianceMod.id("white_disc"));
|
||||
private static final ResourceKey<CreativeModeTab> TOOLS_TAB = ResourceKey.create(
|
||||
Registries.CREATIVE_MODE_TAB, Identifier.withDefaultNamespace("tools_and_utilities"));
|
||||
public static final Item WHITE_DISC = Registry.register(BuiltInRegistries.ITEM, WHITE_DISC_KEY,
|
||||
new WhiteDiscItem(new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON)
|
||||
.jukeboxPlayable(WHITE_DISC_SONG).setId(WHITE_DISC_KEY)));
|
||||
private static boolean initialized;
|
||||
|
||||
private AmbianceRegistries() {
|
||||
@@ -17,7 +37,8 @@ public final class AmbianceRegistries {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
AmbianceMod.LOGGER.debug("[{}] Empty registry hub initialized.", AmbianceMod.DISPLAY_NAME);
|
||||
CreativeModeTabEvents.modifyOutputEvent(TOOLS_TAB).register(entries -> entries.accept(WHITE_DISC));
|
||||
AmbianceMod.LOGGER.debug("[{}] Registry hub initialized with the white disc.",
|
||||
AmbianceMod.DISPLAY_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.white_disc;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload;
|
||||
import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.JukeboxPlayable;
|
||||
import net.minecraft.world.level.block.JukeboxBlock;
|
||||
import net.minecraft.world.level.block.entity.JukeboxBlockEntity;
|
||||
|
||||
/** Server-authoritative insertion and bounded playback dispatch for the white disc. */
|
||||
public final class WhiteDiscService {
|
||||
private static boolean initialized;
|
||||
|
||||
private WhiteDiscService() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
|
||||
BlockPos pos = hit.getBlockPos();
|
||||
if (!(level.getBlockEntity(pos) instanceof JukeboxBlockEntity jukebox)
|
||||
|| jukebox.getBlockState().getValue(JukeboxBlock.HAS_RECORD)) return InteractionResult.PASS;
|
||||
ItemStack held = player.getItemInHand(hand);
|
||||
if (!held.is(AmbianceRegistries.WHITE_DISC)) return InteractionResult.PASS;
|
||||
|
||||
Optional<String> videoId = videoId(held);
|
||||
if (videoId.isEmpty()) {
|
||||
if (!level.isClientSide()) player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.invalid_url"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
InteractionResult result = JukeboxPlayable.tryInsertIntoJukebox(level, pos, held, player);
|
||||
if (result.consumesAction() && level instanceof ServerLevel serverLevel) {
|
||||
broadcast(serverLevel, pos, videoId.get(), player instanceof ServerPlayer serverPlayer
|
||||
? serverPlayer : null);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public static Optional<String> videoId(ItemStack stack) {
|
||||
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||
return name == null ? Optional.empty() : WhiteDiscUrl.videoId(name.getString());
|
||||
}
|
||||
|
||||
private static void broadcast(ServerLevel level, BlockPos pos, String videoId, ServerPlayer inserter) {
|
||||
WhiteDiscPlaybackPayload payload = new WhiteDiscPlaybackPayload(pos, videoId);
|
||||
Set<ServerPlayer> receivers = new LinkedHashSet<>(PlayerLookup.tracking(level, pos));
|
||||
if (inserter != null) receivers.add(inserter);
|
||||
for (ServerPlayer receiver : receivers) {
|
||||
if (ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)) {
|
||||
ServerPlayNetworking.send(receiver, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.white_disc;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Strict, side-effect-free YouTube URL parser used before any client network request. */
|
||||
public final class WhiteDiscUrl {
|
||||
public static final int MAXIMUM_URL_LENGTH = 256;
|
||||
public static final int VIDEO_ID_LENGTH = 11;
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("[A-Za-z0-9_-]{11}");
|
||||
private static final Set<String> LONG_HOSTS = Set.of(
|
||||
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com");
|
||||
|
||||
private WhiteDiscUrl() {
|
||||
}
|
||||
|
||||
public static Optional<String> videoId(String value) {
|
||||
if (value == null || value.isBlank() || value.length() > MAXIMUM_URL_LENGTH) return Optional.empty();
|
||||
try {
|
||||
URI uri = new URI(value.strip());
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null
|
||||
|| (uri.getPort() != -1 && uri.getPort() != 443)) return Optional.empty();
|
||||
String host = uri.getHost();
|
||||
if (host == null) return Optional.empty();
|
||||
host = host.toLowerCase(Locale.ROOT);
|
||||
if ("youtu.be".equals(host)) return valid(singlePathSegment(uri.getRawPath()));
|
||||
if (!LONG_HOSTS.contains(host)) return Optional.empty();
|
||||
|
||||
String path = uri.getRawPath();
|
||||
if ("/watch".equals(path)) return valid(queryParameter(uri.getRawQuery(), "v"));
|
||||
for (String prefix : new String[] {"/shorts/", "/embed/", "/live/"}) {
|
||||
if (path != null && path.startsWith(prefix)) return valid(singlePathSegment(path.substring(prefix.length())));
|
||||
}
|
||||
} catch (URISyntaxException ignored) {
|
||||
// Invalid user text is rejected without network access.
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean validVideoId(String value) {
|
||||
return value != null && VIDEO_ID.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static String canonicalUrl(String videoId) {
|
||||
if (!validVideoId(videoId)) throw new IllegalArgumentException("Invalid YouTube video ID");
|
||||
return "https://www.youtube.com/watch?v=" + videoId;
|
||||
}
|
||||
|
||||
private static String singlePathSegment(String path) {
|
||||
if (path == null || path.isEmpty()) return "";
|
||||
String segment = path.startsWith("/") ? path.substring(1) : path;
|
||||
return segment.contains("/") ? "" : segment;
|
||||
}
|
||||
|
||||
private static String queryParameter(String query, String name) {
|
||||
if (query == null) return "";
|
||||
String found = null;
|
||||
for (String parameter : query.split("&", -1)) {
|
||||
int equals = parameter.indexOf('=');
|
||||
if (equals <= 0 || !name.equals(parameter.substring(0, equals))) continue;
|
||||
if (found != null) return "";
|
||||
found = parameter.substring(equals + 1);
|
||||
}
|
||||
return found == null ? "" : found;
|
||||
}
|
||||
|
||||
private static Optional<String> valid(String candidate) {
|
||||
return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@
|
||||
"package": "fr.koka99cab.sanctuary26.ambiance.mixin.client",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"client": [
|
||||
"CameraMixin",
|
||||
"DynamicEntityRendererMixin",
|
||||
"DynamicItemInHandRendererMixin",
|
||||
"FogRendererMixin",
|
||||
"GameRendererMixin",
|
||||
"LightCoordsUtilMixin",
|
||||
"LightmapRenderStateExtractorMixin",
|
||||
"LevelEventHandlerMixin",
|
||||
"SoundEngineAccessor",
|
||||
"SoundManagerAccessor",
|
||||
"SkyRendererMixin"
|
||||
],
|
||||
"injectors": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "ambiance:item/white_disc"
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,22 @@
|
||||
"command.ambiance.weather.set_day": "Weather set to %s until the next day.",
|
||||
"command.ambiance.weather.set_duration": "Weather set to %1$s for %2$s ticks.",
|
||||
"command.ambiance.weather.status": "Current weather: %s.",
|
||||
"command.ambiance.weather.auto": "Automatic daily weather restored: %s."
|
||||
"command.ambiance.weather.auto": "Automatic daily weather restored: %s.",
|
||||
"options.ambiance.visual.title": "Temporary visual effects",
|
||||
"options.ambiance.visual.intensity": "Effect intensity",
|
||||
"options.ambiance.visual.reduce_motion": "Reduce motion",
|
||||
"options.ambiance.visual.wobble": "Camera wobble",
|
||||
"options.ambiance.visual.ghosting": "Edge ghosting",
|
||||
"key.categories.ambiance.ambiance": "Ambiance",
|
||||
"key.ambiance.visual_settings": "Visual accessibility settings",
|
||||
"item.ambiance.white_disc": "White Disc",
|
||||
"jukebox_song.ambiance.white_disc": "White Disc",
|
||||
"tooltip.ambiance.white_disc": "Rename it to an HTTPS YouTube URL, then insert it into a jukebox.",
|
||||
"message.ambiance.white_disc.invalid_url": "Rename the white disc to a valid HTTPS YouTube URL.",
|
||||
"message.ambiance.white_disc.loading": "Loading YouTube video…",
|
||||
"message.ambiance.white_disc.playing": "Now playing: %s",
|
||||
"message.ambiance.white_disc.failed": "This YouTube video could not be played.",
|
||||
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes.",
|
||||
"message.ambiance.alpha_textures_missing": "Alpha textures are unavailable: install Golden Days Base then Golden Days Alpha in resourcepacks and leave them disabled.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "The previous resource-pack stack could not be reloaded. It will be restored on the next start."
|
||||
}
|
||||
|
||||
@@ -17,5 +17,22 @@
|
||||
"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 d’accessibilité visuelle",
|
||||
"item.ambiance.white_disc": "Disque blanc",
|
||||
"jukebox_song.ambiance.white_disc": "Disque blanc",
|
||||
"tooltip.ambiance.white_disc": "Renommez-le avec une URL YouTube HTTPS, puis insérez-le dans un jukebox.",
|
||||
"message.ambiance.white_disc.invalid_url": "Renommez le disque blanc avec une URL YouTube HTTPS valide.",
|
||||
"message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…",
|
||||
"message.ambiance.white_disc.playing": "Lecture : %s",
|
||||
"message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.",
|
||||
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes.",
|
||||
"message.ambiance.alpha_textures_missing": "Textures Alpha indisponibles : installez Golden Days Base puis Golden Days Alpha dans resourcepacks en les laissant désactivés.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "La pile de packs précédente n’a pas pu être rechargée. Elle sera restaurée au prochain démarrage."
|
||||
}
|
||||
|
||||
@@ -17,5 +17,22 @@
|
||||
"command.ambiance.weather.set_day": "Погода установлена: %s до следующего дня.",
|
||||
"command.ambiance.weather.set_duration": "Погода установлена: %1$s на %2$s тиков.",
|
||||
"command.ambiance.weather.status": "Текущая погода: %s.",
|
||||
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s."
|
||||
"command.ambiance.weather.auto": "Автоматическая погода дня восстановлена: %s.",
|
||||
"options.ambiance.visual.title": "Временные визуальные эффекты",
|
||||
"options.ambiance.visual.intensity": "Интенсивность эффектов",
|
||||
"options.ambiance.visual.reduce_motion": "Уменьшить движение",
|
||||
"options.ambiance.visual.wobble": "Покачивание камеры",
|
||||
"options.ambiance.visual.ghosting": "Шлейф по краям",
|
||||
"key.categories.ambiance.ambiance": "Ambiance",
|
||||
"key.ambiance.visual_settings": "Настройки визуальной доступности",
|
||||
"item.ambiance.white_disc": "Белая пластинка",
|
||||
"jukebox_song.ambiance.white_disc": "Белая пластинка",
|
||||
"tooltip.ambiance.white_disc": "Переименуйте её в HTTPS-ссылку YouTube и вставьте в проигрыватель.",
|
||||
"message.ambiance.white_disc.invalid_url": "Переименуйте белую пластинку в допустимую HTTPS-ссылку YouTube.",
|
||||
"message.ambiance.white_disc.loading": "Загрузка видео YouTube…",
|
||||
"message.ambiance.white_disc.playing": "Сейчас играет: %s",
|
||||
"message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.",
|
||||
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут.",
|
||||
"message.ambiance.alpha_textures_missing": "Текстуры Alpha недоступны: установите Golden Days Base, затем Golden Days Alpha в resourcepacks и оставьте их выключенными.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "Не удалось перезагрузить прежний набор ресурсов. Он будет восстановлен при следующем запуске."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:item/template_music_disc",
|
||||
"textures": {
|
||||
"layer0": "ambiance:item/white_disc"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"comparator_output": 15,
|
||||
"description": {
|
||||
"translate": "jukebox_song.ambiance.white_disc"
|
||||
},
|
||||
"length_in_seconds": 1800.0,
|
||||
"sound_event": "minecraft:intentionally_empty"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
"WWW",
|
||||
"WDW",
|
||||
"WWW"
|
||||
],
|
||||
"key": {
|
||||
"D": "minecraft:music_disc_13",
|
||||
"W": "minecraft:white_dye"
|
||||
},
|
||||
"result": {
|
||||
"id": "ambiance:white_disc"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "ambiance",
|
||||
"version": "${version}",
|
||||
"name": "Ambiance",
|
||||
"description": "Owns world atmosphere, dynamic held lights, real time and the Minecraft calendar.",
|
||||
"description": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"authors": [
|
||||
"KOKA99CAB"
|
||||
],
|
||||
@@ -36,13 +36,13 @@
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "ambiance",
|
||||
"data_version": 2,
|
||||
"network_protocol": 4,
|
||||
"network_protocol": 6,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
"descriptions": {
|
||||
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, le temps réel et le calendrier minecraftien.",
|
||||
"en_us": "Owns world atmosphere, dynamic lights, real time and the Minecraft calendar.",
|
||||
"ru_ru": "Управляет атмосферой, динамическим освещением, реальным временем и календарём Minecraft."
|
||||
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, l'audio positionnel, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
|
||||
"en_us": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"ru_ru": "Управляет атмосферой, динамическим освещением, позиционным звуком, календарём и доступными составными визуальными эффектами, включая Canaplia."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.api.visual;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceVisualAccessibility;
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.AmbianceClientVisualEffects;
|
||||
import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class AmbianceVisualApiSmoke {
|
||||
private AmbianceVisualApiSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] arguments) {
|
||||
AmbianceVisualProfile bounded = new AmbianceVisualProfile(
|
||||
2.0F,
|
||||
-1.0F,
|
||||
0.5F,
|
||||
4.0F,
|
||||
2.0F,
|
||||
-3.0F,
|
||||
3.0F,
|
||||
2.0F,
|
||||
0.4F,
|
||||
0.3F,
|
||||
0.2F
|
||||
);
|
||||
assertEquals(1.0F, bounded.intensity(), "intensity upper bound");
|
||||
assertEquals(0.0F, bounded.tintRed(), "red lower bound");
|
||||
assertEquals(1.0F, bounded.tintBlue(), "blue upper bound");
|
||||
assertEquals(-1.0F, bounded.saturationDelta(), "saturation lower bound");
|
||||
assertEquals(1.0F, bounded.contrastDelta(), "contrast upper bound");
|
||||
|
||||
AmbianceVisualEffectApi.Request request = AmbianceVisualEffectApi.request(
|
||||
Identifier.fromNamespaceAndPath("canaplia", "hemp_visual_sync"),
|
||||
bounded,
|
||||
Integer.MAX_VALUE
|
||||
);
|
||||
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, request.remainingTicks(), "duration bound");
|
||||
AmbianceVisualProfile reduced = request.withReducedMotion(true).profile();
|
||||
assertEquals(0.0F, reduced.ghostingStrength(), "reduced ghosting");
|
||||
assertEquals(0.0F, reduced.wobbleStrength(), "reduced wobble");
|
||||
assertEquals(0.0F, reduced.pulseStrength(), "reduced pulse");
|
||||
|
||||
AmbianceCanapliaVisualPayloads.Normal normal = new AmbianceCanapliaVisualPayloads.Normal(
|
||||
Integer.MAX_VALUE, bounded, Integer.MAX_VALUE);
|
||||
assertEquals(AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, normal.protocol(), "payload protocol bound");
|
||||
assertEquals(AmbianceVisualEffectApi.MAX_DURATION_TICKS, normal.remainingTicks(), "payload duration bound");
|
||||
AmbianceCanapliaVisualPayloads.BadTrip badTrip = new AmbianceCanapliaVisualPayloads.BadTrip(
|
||||
Integer.MAX_VALUE, bounded, -4);
|
||||
assertEquals(0, badTrip.remainingTicks(), "negative payload duration bound");
|
||||
AmbianceClientVisualEffects.apply(normal);
|
||||
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NORMAL,
|
||||
"normal profile active");
|
||||
AmbianceClientVisualEffects.apply(new AmbianceCanapliaVisualPayloads.BadTrip(
|
||||
AmbianceCanapliaVisualPayloads.PROTOCOL_VERSION, bounded, 20));
|
||||
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.BAD_TRIP,
|
||||
"bad-trip profile precedence");
|
||||
AmbianceClientVisualEffects.clear();
|
||||
assertTrue(AmbianceClientVisualEffects.mode() == AmbianceClientVisualEffects.Mode.NONE,
|
||||
"profile cleanup");
|
||||
AmbianceVisualAccessibility accessibility = new AmbianceVisualAccessibility(
|
||||
2.0F, true, true, true);
|
||||
assertEquals(1.0F, accessibility.intensity(), "accessibility intensity bound");
|
||||
|
||||
try {
|
||||
new AmbianceVisualProfile(
|
||||
Float.NaN, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F
|
||||
);
|
||||
throw new AssertionError("non-finite profile value accepted");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertEquals(float expected, float actual, String label) {
|
||||
if (Float.compare(expected, actual) != 0) {
|
||||
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertTrue(boolean condition, String label) {
|
||||
if (!condition) throw new AssertionError(label);
|
||||
}
|
||||
|
||||
private static void assertEquals(int expected, int actual, String label) {
|
||||
if (expected != actual) {
|
||||
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Executable tests for local Golden Days discovery and exact stack composition. */
|
||||
public final class AlphaTexturePackManagerSmoke {
|
||||
private AlphaTexturePackManagerSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
var packs = AlphaTexturePackManager.findPacks(List.of(
|
||||
"vanilla",
|
||||
"file/Golden Days Base 16.2 [1.20 to 26.2].zip",
|
||||
"file/golden_days_alpha_16.2.zip"
|
||||
)).orElseThrow(() -> new AssertionError("Golden Days packs were not detected"));
|
||||
|
||||
List<String> previous = List.of(
|
||||
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip");
|
||||
List<String> desired = AlphaTexturePackManager.selectionWithOverlay(previous, packs);
|
||||
require(desired.equals(List.of("vanilla", "file/custom.zip", packs.base(), packs.alpha())),
|
||||
"Base and Alpha must be appended in bottom-to-top order without disturbing custom packs");
|
||||
require(previous.equals(List.of(
|
||||
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip")),
|
||||
"Composing the overlay must not mutate the captured stack");
|
||||
require(AlphaTexturePackManager.findPacks(List.of("file/golden-days-base.zip")).isEmpty(),
|
||||
"A partial installation must keep the vanilla stack");
|
||||
require(AlphaTexturePackManager.isGoldenDaysPack("FILE/GOLDEN_DAYS_ALPHA.ZIP"),
|
||||
"Pack matching must be case- and separator-insensitive");
|
||||
require(!AlphaTexturePackManager.isGoldenDaysPack("file/custom-alpha.zip"),
|
||||
"Unrelated packs must never be removed from the captured stack");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Deterministic check for Minecraft's four one-second streaming buffers. */
|
||||
public final class LavaplayerAudioStreamSmoke {
|
||||
private static final int FRAME_SAMPLES = 960;
|
||||
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
|
||||
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
|
||||
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
|
||||
|
||||
private LavaplayerAudioStreamSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
|
||||
throw new IllegalStateException("The white-disc playback pitch is not native");
|
||||
}
|
||||
Queue<AudioFrame> frames = new ArrayDeque<>();
|
||||
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
|
||||
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
|
||||
FRAME_SAMPLES, false);
|
||||
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
|
||||
byte[] data = new byte[SOURCE_FRAME_BYTES];
|
||||
for (int offset = 0; offset < data.length; offset += 4) {
|
||||
putLittleEndian(data, offset, (short) (1_000 + index));
|
||||
putLittleEndian(data, offset + 2, (short) (3_000 + index));
|
||||
}
|
||||
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
|
||||
}
|
||||
|
||||
AtomicInteger timedProvides = new AtomicInteger();
|
||||
AtomicInteger nonBlockingProvides = new AtomicInteger();
|
||||
AtomicBoolean destroyed = new AtomicBoolean();
|
||||
AudioPlayer player = (AudioPlayer) Proxy.newProxyInstance(
|
||||
AudioPlayer.class.getClassLoader(), new Class<?>[] {AudioPlayer.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "provide" -> {
|
||||
if (method.getParameterCount() == 2) {
|
||||
timedProvides.incrementAndGet();
|
||||
yield frames.poll();
|
||||
}
|
||||
nonBlockingProvides.incrementAndGet();
|
||||
yield null;
|
||||
}
|
||||
case "destroy" -> {
|
||||
destroyed.set(true);
|
||||
yield null;
|
||||
}
|
||||
case "getPlayingTrack" -> null;
|
||||
case "getVolume" -> 100;
|
||||
case "isPaused", "startTrack" -> false;
|
||||
default -> null;
|
||||
});
|
||||
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
|
||||
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
|
||||
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
|
||||
}
|
||||
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
|
||||
short expected = (short) (2_000 + frame);
|
||||
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
|
||||
int low = Byte.toUnsignedInt(pcm.get());
|
||||
short actual = (short) (low | pcm.get() << 8);
|
||||
if (actual != expected) {
|
||||
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
|
||||
throw new IllegalStateException("PCM frames were not read with bounded waits");
|
||||
}
|
||||
if (stream.getFormat().getSampleRate() != LavaplayerAudioStream.SAMPLE_RATE
|
||||
|| stream.getFormat().isBigEndian()) {
|
||||
throw new IllegalStateException("The OpenAL PCM format is not 48 kHz little-endian");
|
||||
}
|
||||
stream.close();
|
||||
if (!destroyed.get()) {
|
||||
throw new IllegalStateException("Closing the stream did not destroy its player");
|
||||
}
|
||||
}
|
||||
|
||||
private static void putLittleEndian(byte[] data, int offset, short sample) {
|
||||
data[offset] = (byte) sample;
|
||||
data[offset + 1] = (byte) (sample >>> 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import dev.lavalink.youtube.clients.Ios;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Manual network smoke test for the exact YouTube playback path used by white discs. */
|
||||
public final class WhiteDiscPlaybackSmoke {
|
||||
private static final String VIDEO_URL = "https://www.youtube.com/watch?v=Ue5ZBe-GzSM";
|
||||
|
||||
private WhiteDiscPlaybackSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
|
||||
manager.getConfiguration().setOutputFormat(
|
||||
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
|
||||
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
|
||||
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
|
||||
try {
|
||||
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
|
||||
AudioPlayer player = manager.createPlayer();
|
||||
player.playTrack(track);
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
int totalBytes = 0;
|
||||
long squaredSampleSum = 0L;
|
||||
int sampleCount = 0;
|
||||
int peakSample = 0;
|
||||
for (int index = 0; index < 4; index++) {
|
||||
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
|
||||
if (pcm == null) break;
|
||||
totalBytes += pcm.remaining();
|
||||
while (pcm.remaining() >= 2) {
|
||||
int low = Byte.toUnsignedInt(pcm.get());
|
||||
int high = pcm.get();
|
||||
short sample = (short) ((high << 8) | low);
|
||||
int magnitude = Math.abs((int) sample);
|
||||
peakSample = Math.max(peakSample, magnitude);
|
||||
squaredSampleSum += (long) sample * sample;
|
||||
sampleCount++;
|
||||
}
|
||||
}
|
||||
int expectedBytes = LavaplayerAudioStream.SAMPLE_RATE * 2 * 4;
|
||||
if (totalBytes != expectedBytes) {
|
||||
throw new IllegalStateException("YouTube playback did not fill four PCM seconds");
|
||||
}
|
||||
if (stream.decodedBytes() != expectedBytes || stream.paddedSilenceBytes() != 0L) {
|
||||
throw new IllegalStateException("YouTube playback was not 100% decoded PCM: "
|
||||
+ stream.decodedBytes() + " decoded bytes and "
|
||||
+ stream.paddedSilenceBytes() + " padded bytes");
|
||||
}
|
||||
if (stream.decodedTimelineMillis() != 4_000L) {
|
||||
throw new IllegalStateException("PCM cadence mismatch: four seconds of samples cover "
|
||||
+ stream.decodedTimelineMillis() + " ms of the YouTube timeline");
|
||||
}
|
||||
double rmsSample = Math.sqrt((double) squaredSampleSum / sampleCount);
|
||||
if (rmsSample < 256.0 || peakSample < 2_048) {
|
||||
throw new IllegalStateException("YouTube PCM signal is too quiet: RMS "
|
||||
+ Math.round(rmsSample) + ", peak " + peakSample);
|
||||
}
|
||||
System.out.printf(
|
||||
"PCM cadence: 100%% speed, 4000 ms decoded into 4000 ms at %.0f Hz; RMS %.0f, peak %d from %s%n",
|
||||
stream.getFormat().getSampleRate(), rmsSample, peakSample, track.getInfo().title);
|
||||
stream.close();
|
||||
} finally {
|
||||
manager.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<AudioTrack> load(DefaultAudioPlayerManager manager) {
|
||||
CompletableFuture<AudioTrack> result = new CompletableFuture<>();
|
||||
manager.loadItem(VIDEO_URL, new AudioLoadResultHandler() {
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track) {
|
||||
result.complete(track);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playlistLoaded(AudioPlaylist playlist) {
|
||||
AudioTrack selected = playlist.getSelectedTrack();
|
||||
if (selected == null && !playlist.getTracks().isEmpty()) {
|
||||
selected = playlist.getTracks().getFirst();
|
||||
}
|
||||
if (selected == null) result.completeExceptionally(
|
||||
new IllegalStateException("YouTube playlist contained no track"));
|
||||
else result.complete(selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches() {
|
||||
result.completeExceptionally(new IllegalStateException("YouTube returned no match"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(FriendlyException exception) {
|
||||
result.completeExceptionally(exception);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.white_disc;
|
||||
|
||||
/** Executable URL-policy regression test, intentionally independent from a running game. */
|
||||
public final class WhiteDiscUrlSmoke {
|
||||
private static final String VIDEO_ID = "dQw4w9WgXcQ";
|
||||
|
||||
private WhiteDiscUrlSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected");
|
||||
require("Ue5ZBe-GzSM".equals(id("https://youtu.be/Ue5ZBe-GzSM?si=78LSbtBQzUYzhA2Y")),
|
||||
"reported white-disc URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected");
|
||||
|
||||
reject("http://www.youtube.com/watch?v=" + VIDEO_ID);
|
||||
reject("https://youtube.example/watch?v=" + VIDEO_ID);
|
||||
reject("https://youtube.com.evil.example/watch?v=" + VIDEO_ID);
|
||||
reject("https://user@www.youtube.com/watch?v=" + VIDEO_ID);
|
||||
reject("https://www.youtube.com:8443/watch?v=" + VIDEO_ID);
|
||||
reject("https://www.youtube.com/watch?v=too_short");
|
||||
reject("https://www.youtube.com/watch?v=" + VIDEO_ID + "&v=" + VIDEO_ID);
|
||||
reject("https://youtu.be/" + VIDEO_ID + "/extra");
|
||||
reject("https://www.youtube.com/watch?v=dQw4w9WgX%63Q");
|
||||
reject("x".repeat(WhiteDiscUrl.MAXIMUM_URL_LENGTH + 1));
|
||||
}
|
||||
|
||||
private static String id(String url) {
|
||||
return WhiteDiscUrl.videoId(url).orElseThrow();
|
||||
}
|
||||
|
||||
private static void reject(String url) {
|
||||
require(WhiteDiscUrl.videoId(url).isEmpty(), "Unsafe URL accepted: " + url);
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,18 @@ def generatedResources = layout.buildDirectory.dir("generated/resources")
|
||||
|
||||
sourceSets.main.resources.srcDir(generatedResources)
|
||||
|
||||
tasks.register("vaultRulesSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
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"
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
tasks.register("generateAnotherWorldResources") {
|
||||
group = "build"
|
||||
description = "Generates the reproducible Another World 26.2 material catalog."
|
||||
@@ -165,6 +177,72 @@ 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 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 ->
|
||||
def apply = [model: "anotherworld:block/safe_block/${part.id}"]
|
||||
if (rotation != 0) apply.y = rotation
|
||||
safeMultipart.add([when: [formed: "true", facing: facing.toString(), part: part.id], apply: apply])
|
||||
}
|
||||
}
|
||||
writeJson("assets/anotherworld/blockstates/safe_block.json", [multipart: safeMultipart])
|
||||
writeJson("assets/anotherworld/models/block/safe_block_unformed.json", [
|
||||
parent: "minecraft:block/cube_all", textures: [all: "anotherworld:block/safe_block"]
|
||||
])
|
||||
def third = 16.0D / 3.0D
|
||||
def segmentUv = { int column, int row ->
|
||||
[column * third, (2 - row) * third, (column + 1) * third, (3 - row) * third]
|
||||
}
|
||||
vaultParts.each { part ->
|
||||
// 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",
|
||||
bottom: "anotherworld:block/safe_vault/bottom",
|
||||
top: "anotherworld:block/safe_vault/top",
|
||||
front: "anotherworld:block/safe_vault/front",
|
||||
side: "anotherworld:block/safe_vault/side"
|
||||
],
|
||||
elements: [[from: [0, 0, 0], to: [16, 16, 16], faces: [
|
||||
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: westUv, texture: "#side", cullface: "west"],
|
||||
east: [uv: eastUv, texture: "#side", cullface: "east"]
|
||||
]]]
|
||||
])
|
||||
}
|
||||
writeJson("assets/anotherworld/models/item/safe_block.json", [
|
||||
parent: "anotherworld:block/safe_block_unformed"
|
||||
])
|
||||
writeJson("assets/anotherworld/items/safe_block.json", modelItem("anotherworld:item/safe_block"))
|
||||
selfLoot("safe_block")
|
||||
simpleBlock("black_sand", "minecraft:block/cube_all", "anotherworld:block/black_sand")
|
||||
def rainbowColors = ["white", "light_gray", "gray", "black", "brown", "red", "orange", "yellow", "lime", "green", "cyan", "light_blue", "blue", "purple", "magenta", "pink"]
|
||||
rainbowColors.each { color ->
|
||||
@@ -215,6 +293,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
titanium_nugget: "anotherworld:item/titanium_nugget"
|
||||
].each { path, texture -> standaloneItem(path, texture) }
|
||||
standaloneItem("golden_egg", "anotherworld:item/golden_egg")
|
||||
standaloneItem("titanium_drill", "anotherworld:item/titanium_drill")
|
||||
|
||||
// Lootbox-only titanium uniques: diamond-grade properties, 7.5 mining speed and unbreakable.
|
||||
def titaniumEquipment = [
|
||||
@@ -431,6 +510,12 @@ tasks.register("generateAnotherWorldResources") {
|
||||
standaloneItem("${family}_${part}", "anotherworld:item/${family}_${part}")
|
||||
}
|
||||
}
|
||||
shaped("safe_block", "building", ["GGG", "GCG", "GGG"],
|
||||
[G: "minecraft:gold_block", C: "minecraft:chest"], "anotherworld:safe_block")
|
||||
shaped("titanium_drill", "equipment", ["TTT", "RPR", "FDF"], [
|
||||
T: "anotherworld:titanium_ingot", R: "minecraft:redstone", P: "minecraft:piston",
|
||||
F: "minecraft:iron_block", D: "minecraft:diamond_pickaxe"
|
||||
], "anotherworld:titanium_drill")
|
||||
|
||||
// The five full mossy-wood blocks persist four visual growth stages.
|
||||
def mossModel = { String base, int level -> level == 0 ? base : "${base}_moss_${level}" }
|
||||
@@ -652,7 +737,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
tag("item", "minecraft", "stairs", coloredBrickStairs)
|
||||
tag("block", "minecraft", "slabs", coloredBrickSlabs)
|
||||
tag("item", "minecraft", "slabs", coloredBrickSlabs)
|
||||
def pickaxe = ["anotherworld:mirror", "anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:raw_silver_block", "anotherworld:silver_block", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"]
|
||||
def pickaxe = ["anotherworld:mirror", "anotherworld:safe_block", "anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:raw_silver_block", "anotherworld:silver_block", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"]
|
||||
pickaxe.addAll(coloredBricks)
|
||||
pickaxe.addAll(coloredBrickStairs)
|
||||
pickaxe.addAll(coloredBrickSlabs)
|
||||
@@ -660,7 +745,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
tag("block", "minecraft", "mineable/shovel", coloredClay + ["anotherworld:black_sand"])
|
||||
tag("block", "minecraft", "mineable/hoe", coloredLeaves + flowerBlocks + ["anotherworld:turquoise_moss", "anotherworld:giant_flower_stem", "anotherworld:giant_flower_pistil"])
|
||||
tag("block", "minecraft", "needs_iron_tool", ["anotherworld:ruby_ore", "anotherworld:deepslate_ruby_ore", "anotherworld:ruby_block", "anotherworld:raw_silver_block", "anotherworld:silver_block"])
|
||||
tag("block", "minecraft", "needs_diamond_tool", ["anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"])
|
||||
tag("block", "minecraft", "needs_diamond_tool", ["anotherworld:safe_block", "anotherworld:sapphire_ore", "anotherworld:deepslate_sapphire_ore", "anotherworld:sapphire_block", "anotherworld:deepslate_silver_ore", "anotherworld:deepslate_titanium_ore", "anotherworld:raw_titanium_block", "anotherworld:titanium_block"])
|
||||
[
|
||||
"gems/ruby": ["anotherworld:ruby"], "gems/sapphire": ["anotherworld:sapphire"],
|
||||
"raw_materials/silver": ["anotherworld:raw_silver"], "ingots/silver": ["anotherworld:silver_ingot"], "nuggets/silver": ["anotherworld:silver_nugget"],
|
||||
@@ -1452,13 +1537,13 @@ tasks.register("generateAnotherWorldResources") {
|
||||
def languageEntries = [
|
||||
en_us: [
|
||||
description: "Owns the material things that make up the world.", tab: "Another World",
|
||||
mirror: "Mirror", black_sand: "Black Sand", ruby_ore: "Ruby Ore", deepslate_ruby_ore: "Deepslate Ruby Ore", ruby_block: "Block of Ruby",
|
||||
mirror: "Mirror", safe_block: "Safe Block", black_sand: "Black Sand", ruby_ore: "Ruby Ore", deepslate_ruby_ore: "Deepslate Ruby Ore", ruby_block: "Block of Ruby",
|
||||
sapphire_ore: "Sapphire Ore", deepslate_sapphire_ore: "Deepslate Sapphire Ore", sapphire_block: "Block of Sapphire",
|
||||
deepslate_silver_ore: "Deepslate Silver Ore", raw_silver_block: "Block of Raw Silver", silver_block: "Block of Silver",
|
||||
deepslate_titanium_ore: "Deepslate Titanium Ore", raw_titanium_block: "Block of Raw Titanium", titanium_block: "Block of Titanium",
|
||||
ruby: "Ruby", sapphire: "Sapphire", raw_silver: "Raw Silver", silver_ingot: "Silver Ingot", silver_nugget: "Silver Nugget",
|
||||
silver_rod: "Silver Rod", silver_plate: "Silver Plate", silver_gear: "Silver Gear", aircraft_cockpit: "Aircraft Cockpit",
|
||||
raw_titanium: "Raw Titanium", titanium_ingot: "Titanium Ingot", titanium_nugget: "Titanium Nugget", golden_egg: "Golden Egg",
|
||||
raw_titanium: "Raw Titanium", titanium_ingot: "Titanium Ingot", titanium_nugget: "Titanium Nugget", titanium_drill: "Titanium Drill", golden_egg: "Golden Egg",
|
||||
titanium_sword: "Titanium Sword", titanium_shovel: "Titanium Shovel", titanium_pickaxe: "Titanium Pickaxe", titanium_axe: "Titanium Axe",
|
||||
titanium_hoe: "Titanium Hoe", titanium_spear: "Titanium Spear", titanium_shears: "Titanium Shears",
|
||||
titanium_helmet: "Titanium Helmet", titanium_chestplate: "Titanium Chestplate", titanium_leggings: "Titanium Leggings", titanium_boots: "Titanium Boots",
|
||||
@@ -1469,13 +1554,13 @@ tasks.register("generateAnotherWorldResources") {
|
||||
],
|
||||
fr_fr: [
|
||||
description: "Possède les choses matérielles qui composent le monde.", tab: "Another World",
|
||||
mirror: "Miroir", black_sand: "Sable noir", ruby_ore: "Minerai de rubis", deepslate_ruby_ore: "Minerai de rubis des abîmes", ruby_block: "Bloc de rubis",
|
||||
mirror: "Miroir", safe_block: "Bloc de coffre-fort", black_sand: "Sable noir", ruby_ore: "Minerai de rubis", deepslate_ruby_ore: "Minerai de rubis des abîmes", ruby_block: "Bloc de rubis",
|
||||
sapphire_ore: "Minerai de saphir", deepslate_sapphire_ore: "Minerai de saphir des abîmes", sapphire_block: "Bloc de saphir",
|
||||
deepslate_silver_ore: "Minerai d’argent des abîmes", raw_silver_block: "Bloc d’argent brut", silver_block: "Bloc d’argent",
|
||||
deepslate_titanium_ore: "Minerai de titane des abîmes", raw_titanium_block: "Bloc de titane brut", titanium_block: "Bloc de titane",
|
||||
ruby: "Rubis", sapphire: "Saphir", raw_silver: "Argent brut", silver_ingot: "Lingot d’argent", silver_nugget: "Pépite d’argent",
|
||||
silver_rod: "Tige d’argent", silver_plate: "Plaque d’argent", silver_gear: "Engrenage d’argent", aircraft_cockpit: "Cockpit d’avion",
|
||||
raw_titanium: "Titane brut", titanium_ingot: "Lingot de titane", titanium_nugget: "Pépite de titane", golden_egg: "Œuf doré",
|
||||
raw_titanium: "Titane brut", titanium_ingot: "Lingot de titane", titanium_nugget: "Pépite de titane", titanium_drill: "Foreuse en titane", golden_egg: "Œuf doré",
|
||||
titanium_sword: "Épée en titane", titanium_shovel: "Pelle en titane", titanium_pickaxe: "Pioche en titane", titanium_axe: "Hache en titane",
|
||||
titanium_hoe: "Houe en titane", titanium_spear: "Lance en titane", titanium_shears: "Cisailles en titane",
|
||||
titanium_helmet: "Casque en titane", titanium_chestplate: "Plastron en titane", titanium_leggings: "Jambières en titane", titanium_boots: "Bottes en titane",
|
||||
@@ -1486,13 +1571,13 @@ tasks.register("generateAnotherWorldResources") {
|
||||
],
|
||||
ru_ru: [
|
||||
description: "Содержит материальные элементы, из которых состоит мир.", tab: "Another World",
|
||||
mirror: "Зеркало", black_sand: "Чёрный песок", ruby_ore: "Рубиновая руда", deepslate_ruby_ore: "Рубиноносный глубинный сланец", ruby_block: "Рубиновый блок",
|
||||
mirror: "Зеркало", safe_block: "Блок сейфа", black_sand: "Чёрный песок", ruby_ore: "Рубиновая руда", deepslate_ruby_ore: "Рубиноносный глубинный сланец", ruby_block: "Рубиновый блок",
|
||||
sapphire_ore: "Сапфировая руда", deepslate_sapphire_ore: "Сапфироносный глубинный сланец", sapphire_block: "Сапфировый блок",
|
||||
deepslate_silver_ore: "Сереброносный глубинный сланец", raw_silver_block: "Блок необработанного серебра", silver_block: "Серебряный блок",
|
||||
deepslate_titanium_ore: "Титаноносный глубинный сланец", raw_titanium_block: "Блок необработанного титана", titanium_block: "Титановый блок",
|
||||
ruby: "Рубин", sapphire: "Сапфир", raw_silver: "Необработанное серебро", silver_ingot: "Серебряный слиток", silver_nugget: "Кусочек серебра",
|
||||
silver_rod: "Серебряный стержень", silver_plate: "Серебряная пластина", silver_gear: "Серебряная шестерня", aircraft_cockpit: "Кабина самолёта",
|
||||
raw_titanium: "Необработанный титан", titanium_ingot: "Титановый слиток", titanium_nugget: "Кусочек титана", golden_egg: "Золотое яйцо",
|
||||
raw_titanium: "Необработанный титан", titanium_ingot: "Титановый слиток", titanium_nugget: "Кусочек титана", titanium_drill: "Титановая дрель", golden_egg: "Золотое яйцо",
|
||||
titanium_sword: "Титановый меч", titanium_shovel: "Титановая лопата", titanium_pickaxe: "Титановая кирка", titanium_axe: "Титановый топор",
|
||||
titanium_hoe: "Титановая мотыга", titanium_spear: "Титановое копьё", titanium_shears: "Титановые ножницы",
|
||||
titanium_helmet: "Титановый шлем", titanium_chestplate: "Титановый нагрудник", titanium_leggings: "Титановые поножи", titanium_boots: "Титановые ботинки",
|
||||
@@ -1548,6 +1633,83 @@ tasks.register("generateAnotherWorldResources") {
|
||||
lang["options.miroir.reflection.tooltip"] = locale == "fr_fr"
|
||||
? "Dose le mélange entre métal sombre et couleur réfléchie."
|
||||
: locale == "ru_ru" ? "Настраивает смешивание тёмного металла и отражённого цвета." : "Blends dark metal with the reflected color."
|
||||
def vaultText = locale == "fr_fr" ? [
|
||||
"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 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 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.",
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
|
||||
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
|
||||
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort n’est pas actif.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif avant d’en braquer un autre.",
|
||||
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
|
||||
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
|
||||
"message.anotherworld.safe_vault.already_drilling": "Une autre foreuse attaque déjà ce coffre-fort.",
|
||||
"message.anotherworld.safe_vault.alarm": "ALERTE : %s fore votre coffre-fort en %s, %s, %s !",
|
||||
"message.anotherworld.safe_vault.drilling_started": "Forage lancé : gardez la foreuse dirigée vers la structure pendant 60 secondes.",
|
||||
"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) crédité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 × 3 из 26 блоков одного владельца со свободным центром.",
|
||||
"message.anotherworld.safe_vault.locked": "Этот сейф принадлежит другому игроку.",
|
||||
"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": "Разобрать этот блок может только владелец.",
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
|
||||
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
|
||||
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "Для ограбления чужого сейфа нужен собственный активный сейф.",
|
||||
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
|
||||
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
|
||||
"message.anotherworld.safe_vault.already_drilling": "Этот сейф уже сверлит другой игрок.",
|
||||
"message.anotherworld.safe_vault.alarm": "ТРЕВОГА: %s сверлит ваш сейф в точке %s, %s, %s!",
|
||||
"message.anotherworld.safe_vault.drilling_started": "Сверление началось: держите дрель наведённой 60 секунд.",
|
||||
"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 драгоценностей зачислено в ваш сейф."
|
||||
] : [
|
||||
"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 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": "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.",
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
|
||||
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
|
||||
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "You need an active safe of your own before raiding another one.",
|
||||
"message.anotherworld.safe_vault.empty": "This safe is empty.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
|
||||
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
|
||||
"message.anotherworld.safe_vault.already_drilling": "Another drill is already attacking this safe.",
|
||||
"message.anotherworld.safe_vault.alarm": "ALARM: %s is drilling your safe at %s, %s, %s!",
|
||||
"message.anotherworld.safe_vault.drilling_started": "Drilling started: keep aiming at the structure for 60 seconds.",
|
||||
"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: %s gem item(s) credited to your safe."
|
||||
]
|
||||
lang.putAll(vaultText)
|
||||
def tagTranslations = locale == "fr_fr" ? [
|
||||
"tag.item.anotherworld.lavender_logs": "Bûches de lavande", "tag.item.anotherworld.ebony_logs": "Bûches d’ébène", "tag.item.anotherworld.mossy_logs": "Bûches moussues", "tag.item.anotherworld.blueberry_logs": "Bûches de myrtille",
|
||||
"tag.item.anotherworld.colored_bricks": "Blocs de briques colorées", "tag.item.anotherworld.colored_brick_items": "Briques colorées", "tag.item.anotherworld.colored_clay": "Argiles colorées", "tag.item.anotherworld.colored_clay_balls": "Boules d’argile colorées", "tag.item.anotherworld.colored_flower_blocks": "Blocs de fleurs colorés", "tag.item.anotherworld.colored_leaves": "Feuilles colorées",
|
||||
@@ -1574,7 +1736,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
names.findAll { key, ignored -> !(key in ["description", "tab"]) }.each { path, name ->
|
||||
def registry = path in (["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget",
|
||||
"silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit",
|
||||
"raw_titanium", "titanium_ingot", "titanium_nugget", "golden_egg"] + titaniumEquipment) ? "item" : "block"
|
||||
"raw_titanium", "titanium_ingot", "titanium_nugget", "titanium_drill", "golden_egg"] + titaniumEquipment) ? "item" : "block"
|
||||
lang["${registry}.anotherworld.${path}"] = name
|
||||
}
|
||||
rainbowColors.each { color ->
|
||||
@@ -1656,7 +1818,7 @@ tasks.named("sourcesJar") {
|
||||
|
||||
tasks.register("verifyAnotherWorldCatalog") {
|
||||
group = "verification"
|
||||
description = "Checks the Another World alpha.49 catalog and climate-aware biome mixer."
|
||||
description = "Checks the Another World alpha.51 catalog, player vault and climate-aware biome mixer."
|
||||
dependsOn tasks.named("generateAnotherWorldResources")
|
||||
inputs.file(file("src/main/resources/data/anotherworld/sanctuary_biome_catalog.json"))
|
||||
inputs.file(rootProject.file("sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary.json"))
|
||||
@@ -1794,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") {
|
||||
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
|
||||
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
|
||||
]
|
||||
if (project.version.toString() != "0.0.0-alpha.49"
|
||||
if (project.version.toString() != "0.0.0-alpha.57"
|
||||
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|
||||
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|
||||
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
|
||||
@@ -2217,7 +2379,7 @@ tasks.register("verifyAnotherWorldCatalog") {
|
||||
def parts = ["planks", "log", "wood", "stripped_log", "stripped_wood", "leaves", "sapling", "stairs", "slab", "fence", "fence_gate", "door", "trapdoor", "pressure_plate", "button"]
|
||||
def signParts = ["sign", "wall_sign", "hanging_sign", "wall_hanging_sign"]
|
||||
def blocks = [
|
||||
"mirror", "black_sand", "ruby_ore", "deepslate_ruby_ore", "ruby_block", "sapphire_ore", "deepslate_sapphire_ore", "sapphire_block",
|
||||
"mirror", "safe_block", "black_sand", "ruby_ore", "deepslate_ruby_ore", "ruby_block", "sapphire_ore", "deepslate_sapphire_ore", "sapphire_block",
|
||||
"deepslate_silver_ore", "raw_silver_block", "silver_block", "deepslate_titanium_ore", "raw_titanium_block", "titanium_block",
|
||||
"lavender", "pale_lavender", "pink_lavender", "blue_lavender", "dusk_lavender", "hibiscus", "narcissus", "green_bell_mushroom", "brown_shelf_mushroom", "rose", "blue_rose",
|
||||
"turquoise_moss", "giant_flower_stem", "giant_flower_pistil"
|
||||
@@ -2238,7 +2400,7 @@ tasks.register("verifyAnotherWorldCatalog") {
|
||||
"titanium_spear", "titanium_shears", "titanium_helmet", "titanium_chestplate",
|
||||
"titanium_leggings", "titanium_boots"
|
||||
]
|
||||
def standaloneItems = ["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget", "silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit", "raw_titanium", "titanium_ingot", "titanium_nugget", "golden_egg"] + titaniumEquipmentIds
|
||||
def standaloneItems = ["ruby", "sapphire", "raw_silver", "silver_ingot", "silver_nugget", "silver_rod", "silver_plate", "silver_gear", "aircraft_cockpit", "raw_titanium", "titanium_ingot", "titanium_nugget", "titanium_drill", "golden_egg"] + titaniumEquipmentIds
|
||||
standaloneItems.addAll(colors.collect { "${it}_brick" })
|
||||
standaloneItems.addAll(colors.collect { "${it}_clay_ball" })
|
||||
standaloneItems.addAll(woodFamilies.collectMany { family -> ["${family}_boat", "${family}_chest_boat"] })
|
||||
@@ -3098,6 +3260,7 @@ tasks.register("verifyAnotherWorldCatalog") {
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifyAnotherWorldCatalog")
|
||||
dependsOn tasks.named("vaultRulesSmoke")
|
||||
}
|
||||
|
||||
tasks.register("verifyClimateBiomeMixer") {
|
||||
@@ -3110,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") {
|
||||
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
|
||||
def mixer = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.49"
|
||||
if (project.version.toString() != "0.0.0-alpha.57"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.188"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.189"
|
||||
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
|
||||
@@ -3137,3 +3300,33 @@ tasks.register("verifyClimateBiomeMixer") {
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifyClimateBiomeMixer")
|
||||
}
|
||||
|
||||
tasks.register("verifyBackroomMaterialIntegration") {
|
||||
group = "verification"
|
||||
description = "Checks the one-way Another World material integration with Sanctuary Backroom."
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java"))
|
||||
inputs.file(rootProject.file(
|
||||
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java"))
|
||||
doLast {
|
||||
def initializer = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/AnotherWorldMod.java").text
|
||||
def blocks = file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldBlocks.java").text
|
||||
def api = rootProject.file(
|
||||
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text
|
||||
def rootBuild = rootProject.file("build.gradle").text
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.57"
|
||||
|| !initializer.contains("BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS)")
|
||||
|| !blocks.contains('"turquoise_moss"')
|
||||
|| !api.contains("public static void registerAnomalyMaterial")
|
||||
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.131"
|
||||
|| !rootBuild.contains('project(":anotherworld")')
|
||||
|| !rootBuild.contains('implementation project(":sanctuary")')) {
|
||||
throw new GradleException("Another World alpha.57 Backroom material integration is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifyBackroomMaterialIntegration")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import fr.koka99cab.canon.CanonMod;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.network.AnotherWorldNetworking;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldRegistries;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.backroom.BackroomMaterialApi;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.slf4j.Logger;
|
||||
@@ -12,7 +15,7 @@ import org.slf4j.LoggerFactory;
|
||||
public final class AnotherWorldMod implements ModInitializer {
|
||||
public static final String MOD_ID = "anotherworld";
|
||||
public static final String DISPLAY_NAME = "Another World";
|
||||
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 AnotherWorldMod() {
|
||||
@@ -25,6 +28,8 @@ public final class AnotherWorldMod implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
AnotherWorldRegistries.initialize();
|
||||
BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS);
|
||||
VaultService.initialize();
|
||||
WeatherCompassItem.registerFrameInteraction();
|
||||
CanonMod.initialize();
|
||||
AnotherWorldNetworking.initialize();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.block;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultData;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultDrillingService;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultService;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultStructure;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultPart;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
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.ItemStack;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Mirror;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
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.EnumProperty;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
public final class SafeVaultBlock extends BaseEntityBlock {
|
||||
public static final MapCodec<SafeVaultBlock> CODEC = simpleCodec(SafeVaultBlock::new);
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||
public static final BooleanProperty FORMED = BooleanProperty.create("formed");
|
||||
public static final EnumProperty<VaultPart> PART = EnumProperty.create("part", VaultPart.class);
|
||||
|
||||
public SafeVaultBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any()
|
||||
.setValue(FACING, Direction.NORTH)
|
||||
.setValue(FORMED, false)
|
||||
.setValue(PART, VaultPart.CENTER));
|
||||
}
|
||||
|
||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new SafeVaultBlockEntity(pos, state); }
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
|
||||
}
|
||||
@Override protected BlockState rotate(BlockState state, Rotation rotation) {
|
||||
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
|
||||
}
|
||||
@Override protected BlockState mirror(BlockState state, Mirror mirror) {
|
||||
return rotate(state, mirror.getRotation(state.getValue(FACING)));
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING, FORMED, PART);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
|
||||
super.setPlacedBy(level, pos, state, placer, stack);
|
||||
if (!(level instanceof ServerLevel serverLevel) || !(placer instanceof ServerPlayer player)) return;
|
||||
if (level.getBlockEntity(pos) instanceof SafeVaultBlockEntity blockEntity) {
|
||||
blockEntity.setOwner(player.getUUID());
|
||||
VaultService.onSegmentPlaced(serverLevel, pos, player);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
|
||||
Player player, InteractionHand hand, BlockHitResult hit) {
|
||||
if (!stack.is(AnotherWorldItems.TITANIUM_DRILL)) return InteractionResult.TRY_WITH_EMPTY_HAND;
|
||||
if (level.isClientSide()) {
|
||||
player.startUsingItem(hand);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
if (player instanceof ServerPlayer serverPlayer
|
||||
&& VaultDrillingService.begin(serverPlayer, (ServerLevel) level, pos, hand)) {
|
||||
player.startUsingItem(hand);
|
||||
return InteractionResult.SUCCESS_SERVER;
|
||||
}
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
VaultStructure.Formed formed = VaultStructure.find(level, pos).orElse(null);
|
||||
if (formed == null) {
|
||||
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.incomplete"));
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
ServerLevel serverLevel = (ServerLevel) level;
|
||||
VaultData data = VaultData.get(serverLevel.getServer());
|
||||
if (!data.isRegistered(serverLevel, formed.center(), formed.owner())
|
||||
&& !VaultService.tryRegister(serverLevel, formed, player instanceof ServerPlayer sp ? sp : null)) {
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
if (!formed.owner().equals(player.getUUID())) {
|
||||
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.locked"));
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
if (level.getBlockEntity(formed.controller()) instanceof SafeVaultBlockEntity controller) {
|
||||
player.openMenu(controller);
|
||||
return InteractionResult.SUCCESS_SERVER;
|
||||
}
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos,
|
||||
boolean movedByPiston) {
|
||||
if (!movedByPiston) VaultService.onSegmentRemoved(level, pos, state);
|
||||
super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.MenuProvider;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One owned segment of a player vault. The shared inventory lives in versioned world data. */
|
||||
public final class SafeVaultBlockEntity extends BlockEntity implements MenuProvider {
|
||||
private UUID owner;
|
||||
|
||||
public SafeVaultBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(AnotherWorldBlockEntities.SAFE_VAULT, pos, state);
|
||||
}
|
||||
|
||||
public UUID owner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(UUID owner) {
|
||||
this.owner = owner;
|
||||
setChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (owner != null) output.store("Owner", UUIDUtil.CODEC, owner);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
owner = input.read("Owner", UUIDUtil.CODEC).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getDisplayName() {
|
||||
return Component.translatable("container.anotherworld.safe_vault");
|
||||
}
|
||||
|
||||
@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, formed.center(), owner);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import fr.koka99cab.canon.client.CanonClient;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.client.render.MirrorBlockEntityRenderer;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.client.render.AnotherWorldBoatRendering;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlockEntities;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldMenus;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
|
||||
import net.minecraft.client.gui.screens.MenuScreens;
|
||||
|
||||
public final class AnotherWorldClient implements ClientModInitializer {
|
||||
@Override
|
||||
@@ -14,5 +16,6 @@ public final class AnotherWorldClient implements ClientModInitializer {
|
||||
MiroirClientSettings.load();
|
||||
AnotherWorldBoatRendering.initialize();
|
||||
BlockEntityRendererRegistry.register(AnotherWorldBlockEntities.MIRROR, MirrorBlockEntityRenderer::new);
|
||||
MenuScreens.register(AnotherWorldMenus.SAFE_VAULT, SafeVaultScreen::new);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.SafeVaultMenu;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultRules;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Three centered currency balances with explicit one/stack deposit and withdrawal controls. */
|
||||
public final class SafeVaultScreen extends AbstractContainerScreen<SafeVaultMenu> {
|
||||
private static final Identifier TEXTURE = Identifier.withDefaultNamespace(
|
||||
"textures/gui/container/crafting_table.png");
|
||||
private static final String[] LABELS = {"− ×64", "− ×1", "+ ×1", "+ ×64"};
|
||||
private static final int[] BUTTON_X = {7, 43, 105, 135};
|
||||
private static final int[] BUTTON_WIDTH = {34, 30, 28, 34};
|
||||
private static final int SLOT_X = 79;
|
||||
private static final int FIRST_ROW_Y = 17;
|
||||
private static final int ROW_HEIGHT = 22;
|
||||
|
||||
public SafeVaultScreen(SafeVaultMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
titleLabelX = (imageWidth - font.width(title)) / 2;
|
||||
for (int currency = 0; currency < VaultRules.CURRENCY_SLOTS; currency++) {
|
||||
int y = topPos + FIRST_ROW_Y + currency * ROW_HEIGHT;
|
||||
for (int action = 0; action < VaultRules.BUTTONS_PER_CURRENCY; action++) {
|
||||
int buttonId = SafeVaultMenu.buttonId(currency, action);
|
||||
addRenderableWidget(Button.builder(Component.literal(LABELS[action]),
|
||||
button -> sendTransfer(buttonId))
|
||||
.bounds(leftPos + BUTTON_X[action], y, BUTTON_WIDTH[action], 20).build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
|
||||
super.extractBackground(graphics, mouseX, mouseY, tickDelta);
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, TEXTURE, leftPos, topPos, 0.0F, 0.0F,
|
||||
imageWidth, imageHeight, 256, 256);
|
||||
graphics.fill(leftPos + 5, topPos + 15, leftPos + 171, topPos + 80, 0xFFC6C6C6);
|
||||
for (int currency = 0; currency < VaultRules.CURRENCY_SLOTS; currency++) {
|
||||
int x = leftPos + SLOT_X;
|
||||
int y = topPos + FIRST_ROW_Y + currency * ROW_HEIGHT + 1;
|
||||
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(VaultRules.formatBalance(menu.balance(currency)));
|
||||
graphics.text(font, balance, x + 17 - font.width(balance), y + 9, 0xFFFFFFFF, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendTransfer(int buttonId) {
|
||||
if (minecraft != null && minecraft.gameMode != null) {
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.item;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.VaultDrillingService;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.ItemUseAnimation;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
public final class TitaniumDrillItem extends Item {
|
||||
public TitaniumDrillItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUseTick(Level level, LivingEntity entity, ItemStack stack, int remainingUseTicks) {
|
||||
if (entity instanceof ServerPlayer player) VaultDrillingService.tick(player, stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingUseTicks) {
|
||||
if (entity instanceof ServerPlayer player) VaultDrillingService.cancel(player, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public int getUseDuration(ItemStack stack, LivingEntity entity) { return 72_000; }
|
||||
@Override public ItemUseAnimation getUseAnimation(ItemStack stack) { return ItemUseAnimation.NONE; }
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
tooltip.accept(Component.translatable("tooltip.anotherworld.titanium_drill.use").withStyle(ChatFormatting.GRAY));
|
||||
tooltip.accept(Component.translatable("tooltip.anotherworld.titanium_drill.consumed").withStyle(ChatFormatting.DARK_GRAY));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.anotherworld.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.entity.MirrorBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType;
|
||||
@@ -14,6 +15,11 @@ import net.minecraft.world.level.block.entity.BlockEntityTypes;
|
||||
|
||||
public final class AnotherWorldBlockEntities {
|
||||
public static final BlockEntityType<MirrorBlockEntity> MIRROR = registerMirror();
|
||||
public static final BlockEntityType<SafeVaultBlockEntity> SAFE_VAULT = Registry.register(
|
||||
BuiltInRegistries.BLOCK_ENTITY_TYPE,
|
||||
AnotherWorldMod.id("safe_vault"),
|
||||
FabricBlockEntityTypeBuilder.create(SafeVaultBlockEntity::new, AnotherWorldBlocks.SAFE_BLOCK).build()
|
||||
);
|
||||
|
||||
private AnotherWorldBlockEntities() {
|
||||
}
|
||||
@@ -33,7 +39,7 @@ public final class AnotherWorldBlockEntities {
|
||||
hangingSignType.addValidBlock(family.hangingSign());
|
||||
hangingSignType.addValidBlock(family.wallHangingSign());
|
||||
});
|
||||
AnotherWorldMod.LOGGER.debug("[{}] Mirror block entity and custom sign compatibility registered.", AnotherWorldMod.DISPLAY_NAME);
|
||||
AnotherWorldMod.LOGGER.debug("[{}] Mirror, safe-vault and custom sign block entities registered.", AnotherWorldMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
private static BlockEntityType<MirrorBlockEntity> registerMirror() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import fr.koka99cab.sanctuary26.anotherworld.block.PricklyRoseBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.ProgressiveMossBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.ProgressiveMossPillarBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.RainbowBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.SafeVaultBlock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -55,6 +56,7 @@ import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.level.block.state.properties.BlockSetType;
|
||||
import net.minecraft.world.level.block.state.properties.WoodType;
|
||||
import net.minecraft.world.level.material.MapColor;
|
||||
import net.minecraft.world.level.material.PushReaction;
|
||||
|
||||
public final class AnotherWorldBlocks {
|
||||
public static final List<String> DYE_COLORS = List.of(
|
||||
@@ -93,6 +95,16 @@ public final class AnotherWorldBlocks {
|
||||
.sound(SoundType.GLASS)
|
||||
.noOcclusion()
|
||||
);
|
||||
public static final SafeVaultBlock SAFE_BLOCK = register(
|
||||
"safe_block",
|
||||
SafeVaultBlock::new,
|
||||
BlockBehaviour.Properties.ofFullCopy(Blocks.IRON_BLOCK)
|
||||
.mapColor(MapColor.GOLD)
|
||||
.requiresCorrectToolForDrops()
|
||||
.strength(8.0F, 1_200.0F)
|
||||
.sound(SoundType.METAL)
|
||||
.pushReaction(PushReaction.BLOCK)
|
||||
);
|
||||
|
||||
public static final Map<String, RainbowBlock> RAINBOW_BLOCKS = registerRainbowBlocks();
|
||||
public static final Map<String, Block> COLORED_BRICKS = registerColoredBlocks("bricks", Blocks.BRICKS);
|
||||
|
||||
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.anotherworld.registry;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumEquipment;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumDrillItem;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
@@ -54,6 +55,11 @@ public final class AnotherWorldItems {
|
||||
public static final Item RAW_TITANIUM = register("raw_titanium");
|
||||
public static final Item TITANIUM_INGOT = registerTrimMaterial("titanium_ingot", TITANIUM_TRIM_MATERIAL);
|
||||
public static final Item TITANIUM_NUGGET = register("titanium_nugget");
|
||||
public static final Item TITANIUM_DRILL = register(
|
||||
"titanium_drill",
|
||||
TitaniumDrillItem::new,
|
||||
new Item.Properties().stacksTo(1)
|
||||
);
|
||||
public static final Item TITANIUM_SWORD = register(
|
||||
"titanium_sword",
|
||||
Item::new,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.vault.SafeVaultMenu;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
|
||||
public final class AnotherWorldMenus {
|
||||
public static final MenuType<SafeVaultMenu> SAFE_VAULT = Registry.register(
|
||||
BuiltInRegistries.MENU,
|
||||
AnotherWorldMod.id("safe_vault"),
|
||||
new MenuType<>(SafeVaultMenu::new, FeatureFlags.VANILLA_SET)
|
||||
);
|
||||
|
||||
private AnotherWorldMenus() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
AnotherWorldMod.LOGGER.debug("[{}] Safe-vault menu registered.", AnotherWorldMod.DISPLAY_NAME);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public final class AnotherWorldRegistries {
|
||||
AnotherWorldEntities.initialize();
|
||||
AnotherWorldItems.initialize();
|
||||
AnotherWorldBlockEntities.initialize();
|
||||
AnotherWorldMenus.initialize();
|
||||
AnotherWorldCreativeTab.initialize();
|
||||
AnotherWorldWorldGeneration.initialize();
|
||||
AnotherWorldMod.LOGGER.info(
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldItems;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldMenus;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.ContainerData;
|
||||
import net.minecraft.world.inventory.SimpleContainerData;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Server-authoritative three-currency vault controls backed by the existing saved account. */
|
||||
public final class SafeVaultMenu extends AbstractContainerMenu {
|
||||
private static final int PLAYER_INVENTORY_Y = 108;
|
||||
private static final List<Item> CURRENCIES = List.of(
|
||||
AnotherWorldItems.RUBY,
|
||||
AnotherWorldItems.SAPPHIRE,
|
||||
Items.EMERALD
|
||||
);
|
||||
|
||||
private final ContainerData balances;
|
||||
private final VaultData vaultData;
|
||||
private final ServerLevel serverLevel;
|
||||
private final UUID owner;
|
||||
private final BlockPos center;
|
||||
|
||||
public SafeVaultMenu(int containerId, Inventory inventory) {
|
||||
this(containerId, inventory, new SimpleContainerData(VaultRules.BALANCE_DATA_SLOTS),
|
||||
null, null, null, null);
|
||||
}
|
||||
|
||||
public SafeVaultMenu(int containerId, Inventory inventory, VaultData data,
|
||||
ServerLevel level, BlockPos center, UUID owner) {
|
||||
this(containerId, inventory, new VaultBalanceData(data, owner), data, level,
|
||||
center.immutable(), owner);
|
||||
}
|
||||
|
||||
private SafeVaultMenu(int containerId, Inventory inventory, ContainerData balances,
|
||||
VaultData vaultData, ServerLevel serverLevel, BlockPos center, UUID owner) {
|
||||
super(AnotherWorldMenus.SAFE_VAULT, containerId);
|
||||
checkContainerDataCount(balances, VaultRules.BALANCE_DATA_SLOTS);
|
||||
this.balances = balances;
|
||||
this.vaultData = vaultData;
|
||||
this.serverLevel = serverLevel;
|
||||
this.center = center;
|
||||
this.owner = owner;
|
||||
// Leave all three currency rows visible above Sanctuary's extended inventory panel.
|
||||
addStandardInventorySlots(inventory, 8, PLAYER_INVENTORY_Y);
|
||||
addDataSlots(balances);
|
||||
}
|
||||
|
||||
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) {
|
||||
return CURRENCIES.get(Math.clamp(currency, 0, CURRENCIES.size() - 1));
|
||||
}
|
||||
|
||||
public static int buttonId(int currency, int action) {
|
||||
return currency * VaultRules.BUTTONS_PER_CURRENCY + action;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clickMenuButton(Player player, int buttonId) {
|
||||
if (!(player instanceof ServerPlayer serverPlayer) || vaultData == null || !stillValid(player)) return false;
|
||||
int currencyIndex = VaultRules.buttonCurrency(buttonId);
|
||||
int amount = VaultRules.buttonAmount(buttonId);
|
||||
if (currencyIndex < 0 || amount <= 0) return false;
|
||||
Item item = currency(currencyIndex);
|
||||
int moved = VaultRules.buttonDeposits(buttonId)
|
||||
? deposit(serverPlayer, item, amount)
|
||||
: withdraw(serverPlayer, item, amount);
|
||||
if (moved <= 0) {
|
||||
serverPlayer.sendOverlayMessage(Component.translatable(
|
||||
VaultRules.buttonDeposits(buttonId)
|
||||
? "message.anotherworld.safe_vault.deposit_failed"
|
||||
: "message.anotherworld.safe_vault.withdraw_failed",
|
||||
new ItemStack(item).getHoverName()));
|
||||
}
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack quickMoveStack(Player player, int index) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean stillValid(Player player) {
|
||||
if (serverLevel == null || vaultData == null || center == null || owner == null) return true;
|
||||
if (!player.getUUID().equals(owner)
|
||||
|| player.distanceToSqr(center.getX() + 0.5D, center.getY() + 0.5D, center.getZ() + 0.5D) > 64.0D) {
|
||||
return false;
|
||||
}
|
||||
return vaultData.isRegistered(serverLevel, center, owner)
|
||||
&& 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, available);
|
||||
if (amount <= 0) return 0;
|
||||
int removed = removePlayerItem(player, item, amount);
|
||||
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 = (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);
|
||||
int inserted = insertPlayerItem(player, item, removed);
|
||||
if (inserted < removed) vaultData.add(owner, item, removed - inserted);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
private static int countPlayerItem(ServerPlayer player, Item item) {
|
||||
long count = 0L;
|
||||
for (var slot : SanctuaryPlayerInventoryView.unlockedRows(player)) {
|
||||
if (slot.stack().is(item)) count += slot.stack().getCount();
|
||||
}
|
||||
return (int) Math.min(Integer.MAX_VALUE, count);
|
||||
}
|
||||
|
||||
private static int removePlayerItem(ServerPlayer player, Item item, int amount) {
|
||||
int remaining = amount;
|
||||
for (var slot : SanctuaryPlayerInventoryView.unlockedRows(player)) {
|
||||
if (remaining <= 0) break;
|
||||
ItemStack stack = slot.stack();
|
||||
if (!stack.is(item)) continue;
|
||||
int removed = Math.min(remaining, stack.getCount());
|
||||
slot.remove(removed);
|
||||
remaining -= removed;
|
||||
}
|
||||
return amount - remaining;
|
||||
}
|
||||
|
||||
private static int playerCapacity(ServerPlayer player, Item item) {
|
||||
long capacity = 0L;
|
||||
for (var slot : SanctuaryPlayerInventoryView.unlockedRows(player)) {
|
||||
ItemStack stack = slot.stack();
|
||||
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);
|
||||
}
|
||||
|
||||
private static int insertPlayerItem(ServerPlayer player, Item item, int amount) {
|
||||
int remaining = amount;
|
||||
var slots = SanctuaryPlayerInventoryView.unlockedRows(player);
|
||||
for (var slot : slots) {
|
||||
if (remaining <= 0) break;
|
||||
ItemStack stack = slot.stack();
|
||||
if (!stack.is(item) || stack.getCount() >= stack.getMaxStackSize()) continue;
|
||||
int inserted = Math.min(remaining, stack.getMaxStackSize() - stack.getCount());
|
||||
stack.grow(inserted);
|
||||
slot.markChanged();
|
||||
remaining -= inserted;
|
||||
}
|
||||
for (var slot : slots) {
|
||||
if (remaining <= 0) break;
|
||||
if (!slot.stack().isEmpty()) continue;
|
||||
int inserted = Math.min(remaining, item.getDefaultMaxStackSize());
|
||||
slot.container().setItem(slot.slot(), new ItemStack(item, inserted));
|
||||
slot.markChanged();
|
||||
remaining -= inserted;
|
||||
}
|
||||
return amount - remaining;
|
||||
}
|
||||
|
||||
private record VaultBalanceData(VaultData data, UUID owner) implements ContainerData {
|
||||
@Override public int get(int index) {
|
||||
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.BALANCE_DATA_SLOTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
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.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 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 = 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)
|
||||
).apply(instance, StoredSlot::new));
|
||||
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(
|
||||
Codec.STRING.fieldOf("dimension").forGetter(VaultAddress::dimension),
|
||||
Codec.LONG.fieldOf("pos").forGetter(VaultAddress::pos),
|
||||
Codec.STRING.fieldOf("owner").forGetter(VaultAddress::owner)
|
||||
).apply(instance, VaultAddress::new));
|
||||
private static final Codec<VaultData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
|
||||
ACCOUNT_CODEC.listOf().optionalFieldOf("accounts", List.of()).forGetter(VaultData::storedAccounts),
|
||||
ADDRESS_CODEC.listOf().optionalFieldOf("vaults", List.of()).forGetter(data -> data.vaults)
|
||||
).apply(instance, VaultData::new));
|
||||
private static final SavedDataType<VaultData> TYPE = new SavedDataType<>(
|
||||
AnotherWorldMod.id(SAVE_KEY), VaultData::new, CODEC, null);
|
||||
|
||||
private int dataVersion;
|
||||
private final Map<UUID, Account> accounts = new LinkedHashMap<>();
|
||||
private final List<VaultAddress> vaults = new ArrayList<>();
|
||||
|
||||
public VaultData() {
|
||||
this(DATA_VERSION, List.of(), List.of());
|
||||
}
|
||||
|
||||
private VaultData(int dataVersion, List<StoredAccount> accounts, List<VaultAddress> vaults) {
|
||||
this.dataVersion = dataVersion;
|
||||
for (StoredAccount stored : accounts == null ? List.<StoredAccount>of() : accounts) {
|
||||
try {
|
||||
UUID owner = UUID.fromString(stored.owner());
|
||||
Account account = new Account(stored.rubies(), stored.sapphires(), stored.emeralds(),
|
||||
stored.protectedUntil());
|
||||
for (StoredSlot slot : stored.items()) {
|
||||
int currency = currencyIndex(slot.item());
|
||||
if (currency >= 0) account.add(currency, slot.item().getCount());
|
||||
}
|
||||
this.accounts.put(owner, account);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
AnotherWorldMod.LOGGER.warn("[Vault] Ignored account with invalid owner {}.", stored.owner());
|
||||
}
|
||||
}
|
||||
if (vaults != null) this.vaults.addAll(vaults);
|
||||
}
|
||||
|
||||
public static VaultData get(MinecraftServer server) {
|
||||
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
|
||||
if (overworld == null) throw new IllegalStateException("Overworld unavailable for vault data");
|
||||
VaultData data = overworld.getDataStorage().computeIfAbsent(TYPE);
|
||||
if (data.dataVersion < DATA_VERSION) {
|
||||
data.dataVersion = DATA_VERSION;
|
||||
data.setDirty();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public boolean register(ServerLevel level, BlockPos center, UUID owner) {
|
||||
String dimension = dimension(level);
|
||||
String ownerId = owner.toString();
|
||||
for (VaultAddress address : vaults) {
|
||||
if (address.owner().equals(ownerId)) {
|
||||
return address.dimension().equals(dimension) && address.pos() == center.asLong();
|
||||
}
|
||||
}
|
||||
vaults.add(new VaultAddress(dimension, center.asLong(), ownerId));
|
||||
accounts.computeIfAbsent(owner, ignored -> new Account());
|
||||
setDirty();
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isRegistered(ServerLevel level, BlockPos center, UUID owner) {
|
||||
String dimension = dimension(level), ownerId = owner.toString();
|
||||
return vaults.stream().anyMatch(address -> address.dimension().equals(dimension)
|
||||
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
|
||||
}
|
||||
|
||||
public boolean hasRegisteredVault(UUID owner) {
|
||||
if (owner == null) return false;
|
||||
String ownerId = owner.toString();
|
||||
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
|
||||
}
|
||||
|
||||
public void unregisterNear(ServerLevel level, BlockPos member) {
|
||||
String dimension = dimension(level);
|
||||
boolean changed = vaults.removeIf(address -> {
|
||||
if (!address.dimension().equals(dimension)) return false;
|
||||
BlockPos center = BlockPos.of(address.pos());
|
||||
return Math.abs(center.getX() - member.getX()) <= 1
|
||||
&& Math.abs(center.getY() - member.getY()) <= 1
|
||||
&& Math.abs(center.getZ() - member.getZ()) <= 1;
|
||||
});
|
||||
if (changed) setDirty();
|
||||
}
|
||||
|
||||
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 countCapped(UUID owner, Item item) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, count(owner, item));
|
||||
}
|
||||
|
||||
public int remove(UUID owner, Item item, int amount) {
|
||||
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 (currency < 0 || account == null || amount <= 0L) return 0L;
|
||||
long removed = account.remove(currency, amount);
|
||||
if (removed > 0L) setDirty();
|
||||
return removed;
|
||||
}
|
||||
|
||||
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.hasContents();
|
||||
}
|
||||
|
||||
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
|
||||
public long transferRaid(UUID owner, UUID attacker) {
|
||||
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
|
||||
Account source = accounts.get(owner);
|
||||
if (source == null) return 0L;
|
||||
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
|
||||
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);
|
||||
}
|
||||
if (total > 0L) setDirty();
|
||||
return total;
|
||||
}
|
||||
|
||||
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
|
||||
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
|
||||
}
|
||||
|
||||
public long protectionRemaining(UUID owner, long gameTime) {
|
||||
Account account = accounts.get(owner);
|
||||
return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime);
|
||||
}
|
||||
|
||||
public void protectAfterRaid(UUID owner, long gameTime) {
|
||||
Account account = accounts.computeIfAbsent(owner, ignored -> new Account());
|
||||
account.protectedUntil = gameTime + VaultRules.RAID_PROTECTION_TICKS;
|
||||
setDirty();
|
||||
}
|
||||
|
||||
public static boolean isCurrency(ItemStack stack) {
|
||||
return stack != null && !stack.isEmpty() && currencyIndex(stack) >= 0;
|
||||
}
|
||||
|
||||
public static boolean isCurrency(Item item) {
|
||||
return currencyIndex(item) >= 0;
|
||||
}
|
||||
|
||||
private List<StoredAccount> storedAccounts() {
|
||||
List<StoredAccount> stored = new ArrayList<>();
|
||||
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 long[] balances = new long[VaultRules.CURRENCY_SLOTS];
|
||||
private long protectedUntil;
|
||||
|
||||
private Account() {
|
||||
this(0L, 0L, 0L, 0L);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private boolean hasContents() {
|
||||
for (long balance : balances) if (balance > 0L) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private record StoredSlot(int slot, ItemStack item) {}
|
||||
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) {}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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.Map;
|
||||
import java.util.UUID;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.network.chat.Component;
|
||||
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.item.ItemStack;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.HitResult;
|
||||
|
||||
/** Server-authoritative continuous titanium-drill raid session. */
|
||||
public final class VaultDrillingService {
|
||||
private static final Map<UUID, Session> SESSIONS = new HashMap<>();
|
||||
private static final Map<Target, UUID> ACTIVE_TARGETS = new HashMap<>();
|
||||
private static boolean initialized;
|
||||
|
||||
private VaultDrillingService() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> cancel(handler.player, false));
|
||||
}
|
||||
|
||||
public static boolean begin(ServerPlayer attacker, ServerLevel level, BlockPos clicked, InteractionHand hand) {
|
||||
VaultStructure.Formed formed = VaultStructure.find(level, clicked).orElse(null);
|
||||
if (formed == null) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.incomplete"));
|
||||
return false;
|
||||
}
|
||||
if (formed.owner().equals(attacker.getUUID())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.drill_own"));
|
||||
return false;
|
||||
}
|
||||
VaultData data = VaultData.get(level.getServer());
|
||||
if (!data.isRegistered(level, formed.center(), formed.owner())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
|
||||
return false;
|
||||
}
|
||||
if (!data.hasRegisteredVault(attacker.getUUID())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
|
||||
return false;
|
||||
}
|
||||
if (!data.hasContents(formed.owner())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
|
||||
return false;
|
||||
}
|
||||
ServerPlayer owner = level.getServer().getPlayerList().getPlayer(formed.owner());
|
||||
if (owner == null) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.owner_offline"));
|
||||
return false;
|
||||
}
|
||||
long remaining = data.protectionRemaining(formed.owner(), level.getServer().overworld().getGameTime());
|
||||
if (remaining > 0L) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.protected",
|
||||
Math.max(1L, (remaining + 1199L) / 1200L)));
|
||||
return false;
|
||||
}
|
||||
Target target = Target.of(level, formed.center());
|
||||
UUID active = ACTIVE_TARGETS.get(target);
|
||||
if (active != null && !active.equals(attacker.getUUID())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.already_drilling"));
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel(attacker, false);
|
||||
Session session = new Session(target, formed.owner(), hand, 0);
|
||||
SESSIONS.put(attacker.getUUID(), session);
|
||||
ACTIVE_TARGETS.put(target, attacker.getUUID());
|
||||
owner.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.alarm",
|
||||
attacker.getDisplayName(), formed.center().getX(), formed.center().getY(), formed.center().getZ()));
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.drilling_started"));
|
||||
level.playSound(null, clicked, SoundEvents.PISTON_EXTEND, SoundSource.BLOCKS, 1.0F, 0.65F);
|
||||
AnotherWorldMod.LOGGER.info("[Vault] {} started drilling {}'s vault at {}.",
|
||||
attacker.getGameProfile().name(), owner.getGameProfile().name(), formed.center().toShortString());
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void tick(ServerPlayer attacker, ItemStack drill) {
|
||||
Session session = SESSIONS.get(attacker.getUUID());
|
||||
if (session == null) return;
|
||||
if (!drill.is(AnotherWorldItems.TITANIUM_DRILL)
|
||||
|| attacker.getUsedItemHand() != session.hand()
|
||||
|| !(attacker.level() instanceof ServerLevel level)
|
||||
|| !session.target().dimension().equals(dimension(level))) {
|
||||
cancel(attacker, true);
|
||||
return;
|
||||
}
|
||||
|
||||
HitResult hit = attacker.pick(attacker.blockInteractionRange() + 1.0D, 1.0F, false);
|
||||
VaultStructure.Formed formed = hit instanceof BlockHitResult blockHit
|
||||
? VaultStructure.find(level, blockHit.getBlockPos()).orElse(null) : null;
|
||||
if (formed == null || !formed.center().equals(session.target().center())
|
||||
|| !formed.owner().equals(session.owner())
|
||||
|| level.getServer().getPlayerList().getPlayer(session.owner()) == null) {
|
||||
cancel(attacker, true);
|
||||
attacker.stopUsingItem();
|
||||
return;
|
||||
}
|
||||
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
|
||||
cancel(attacker, false);
|
||||
attacker.sendOverlayMessage(Component.translatable(
|
||||
"message.anotherworld.safe_vault.raid_requires_vault"));
|
||||
attacker.stopUsingItem();
|
||||
return;
|
||||
}
|
||||
|
||||
int elapsed = session.elapsedTicks() + 1;
|
||||
Session progressed = session.withElapsed(elapsed);
|
||||
SESSIONS.put(attacker.getUUID(), progressed);
|
||||
if (elapsed % 10 == 0) {
|
||||
BlockPos center = formed.center();
|
||||
level.playSound(null, center, SoundEvents.ANVIL_LAND, SoundSource.BLOCKS, 0.35F, 1.65F);
|
||||
level.sendParticles(ParticleTypes.ELECTRIC_SPARK, center.getX() + 0.5D, center.getY() + 1.0D,
|
||||
center.getZ() + 0.5D, 5, 0.7D, 0.25D, 0.7D, 0.04D);
|
||||
}
|
||||
if (elapsed % 20 == 0) {
|
||||
attacker.sendOverlayMessage(Component.translatable("message.anotherworld.safe_vault.drilling_progress",
|
||||
VaultRules.progressPercent(elapsed)));
|
||||
}
|
||||
if (elapsed >= VaultRules.RAID_DURATION_TICKS) complete(attacker, level, progressed, formed);
|
||||
}
|
||||
|
||||
private static void complete(ServerPlayer attacker, ServerLevel level, Session session,
|
||||
VaultStructure.Formed formed) {
|
||||
VaultData data = VaultData.get(level.getServer());
|
||||
long total = data.transferRaid(session.owner(), attacker.getUUID());
|
||||
if (total <= 0L) {
|
||||
cancel(attacker, true);
|
||||
attacker.stopUsingItem();
|
||||
return;
|
||||
}
|
||||
data.protectAfterRaid(session.owner(), level.getServer().overworld().getGameTime());
|
||||
ItemStack drill = attacker.getItemInHand(session.hand());
|
||||
if (!attacker.hasInfiniteMaterials()) drill.shrink(1);
|
||||
ServerPlayer owner = level.getServer().getPlayerList().getPlayer(session.owner());
|
||||
if (owner != null) owner.sendSystemMessage(Component.translatable(
|
||||
"message.anotherworld.safe_vault.raided_owner", attacker.getDisplayName(), total));
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raided", total));
|
||||
level.playSound(null, formed.center(), SoundEvents.VAULT_OPEN_SHUTTER, SoundSource.BLOCKS, 1.0F, 0.8F);
|
||||
level.sendParticles(ParticleTypes.CRIT, formed.center().getX() + 0.5D, formed.center().getY() + 1.0D,
|
||||
formed.center().getZ() + 0.5D, 24, 0.8D, 0.4D, 0.8D, 0.12D);
|
||||
AnotherWorldMod.LOGGER.info("[Vault] {} stole {} currency items from {} at {}.",
|
||||
attacker.getGameProfile().name(), total, session.owner(), formed.center().toShortString());
|
||||
cancel(attacker, false);
|
||||
attacker.stopUsingItem();
|
||||
}
|
||||
|
||||
public static void cancel(ServerPlayer player, boolean notify) {
|
||||
Session removed = SESSIONS.remove(player.getUUID());
|
||||
if (removed == null) return;
|
||||
ACTIVE_TARGETS.remove(removed.target(), player.getUUID());
|
||||
if (notify) player.sendOverlayMessage(Component.translatable("message.anotherworld.safe_vault.drilling_cancelled"));
|
||||
}
|
||||
|
||||
public static boolean isActive(ServerLevel level, BlockPos center) {
|
||||
return ACTIVE_TARGETS.containsKey(Target.of(level, center));
|
||||
}
|
||||
|
||||
private static String dimension(ServerLevel level) {
|
||||
return level.dimension().identifier().toString();
|
||||
}
|
||||
|
||||
private record Target(String dimension, BlockPos center) {
|
||||
private static Target of(ServerLevel level, BlockPos center) {
|
||||
return new Target(VaultDrillingService.dimension(level), center.immutable());
|
||||
}
|
||||
}
|
||||
|
||||
private record Session(Target target, UUID owner, InteractionHand hand, int elapsedTicks) {
|
||||
private Session withElapsed(int elapsed) { return new Session(target, owner, hand, elapsed); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import net.minecraft.util.StringRepresentable;
|
||||
|
||||
/** Stable position of one segment in the front-facing 3x3x3 vault cube. */
|
||||
public enum VaultPart implements StringRepresentable {
|
||||
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, int depth) {
|
||||
this.serializedName = serializedName;
|
||||
this.column = column;
|
||||
this.row = row;
|
||||
this.depth = depth;
|
||||
}
|
||||
|
||||
public int column() {
|
||||
return column;
|
||||
}
|
||||
|
||||
public int row() {
|
||||
return 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 && part.depth == depth) return part;
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid vault part: " + column + ", " + row + ", " + depth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSerializedName() {
|
||||
return serializedName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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_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;
|
||||
|
||||
private VaultRules() {
|
||||
}
|
||||
|
||||
public static long raidLoot(long stored) {
|
||||
if (stored <= 0) return 0;
|
||||
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) {
|
||||
return Math.clamp((int) ((long) Math.max(0, elapsedTicks) * 100L / RAID_DURATION_TICKS), 0, 100);
|
||||
}
|
||||
|
||||
public static boolean isCompleteStructureMask(int partMask) {
|
||||
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;
|
||||
}
|
||||
|
||||
public static int buttonAmount(int buttonId) {
|
||||
if (!validButton(buttonId)) return 0;
|
||||
return switch (buttonId % BUTTONS_PER_CURRENCY) {
|
||||
case 0, 3 -> 64;
|
||||
case 1, 2 -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean buttonDeposits(int buttonId) {
|
||||
return validButton(buttonId) && buttonId % BUTTONS_PER_CURRENCY >= 2;
|
||||
}
|
||||
|
||||
private static boolean validButton(int buttonId) {
|
||||
return buttonId >= 0 && buttonId < CURRENCY_SLOTS * BUTTONS_PER_CURRENCY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.SafeVaultBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryCurrencyStorage;
|
||||
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.permissions.Permissions;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Owns vault lifecycle, break protection and Sanctuary currency-storage integration. */
|
||||
public final class VaultService {
|
||||
private static boolean initialized;
|
||||
|
||||
private VaultService() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
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()).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);
|
||||
}
|
||||
});
|
||||
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
|
||||
if (!state.is(AnotherWorldBlocks.SAFE_BLOCK) || !(level instanceof ServerLevel serverLevel)
|
||||
|| !(blockEntity instanceof SafeVaultBlockEntity segment)) return true;
|
||||
if (isOperator(player)) return true;
|
||||
if (segment.owner() == null || !segment.owner().equals(player.getUUID())) {
|
||||
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.break_not_owner"));
|
||||
return false;
|
||||
}
|
||||
VaultStructure.Formed formed = VaultStructure.find(serverLevel, pos).orElse(null);
|
||||
if (formed != null && (VaultData.get(serverLevel.getServer()).hasContents(formed.owner())
|
||||
|| VaultDrillingService.isActive(serverLevel, formed.center()))) {
|
||||
player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty_before_break"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
VaultDrillingService.initialize();
|
||||
}
|
||||
|
||||
public static void onSegmentPlaced(ServerLevel level, net.minecraft.core.BlockPos pos, ServerPlayer player) {
|
||||
VaultStructure.find(level, pos).ifPresent(formed -> tryRegister(level, formed, player));
|
||||
}
|
||||
|
||||
public static void onSegmentRemoved(ServerLevel level, net.minecraft.core.BlockPos pos, BlockState state) {
|
||||
VaultData.get(level.getServer()).unregisterNear(level, pos);
|
||||
if (!state.getValue(SafeVaultBlock.FORMED)) return;
|
||||
net.minecraft.core.BlockPos center = VaultStructure.centerFor(pos, state);
|
||||
var facing = state.getValue(SafeVaultBlock.FACING);
|
||||
var width = facing.getClockWise();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean tryRegister(ServerLevel level, VaultStructure.Formed formed, ServerPlayer player) {
|
||||
VaultData data = VaultData.get(level.getServer());
|
||||
if (data.register(level, formed.center(), formed.owner())) {
|
||||
formed.members().forEach(member -> level.setBlock(member,
|
||||
level.getBlockState(member)
|
||||
.setValue(SafeVaultBlock.FACING, formed.facing())
|
||||
.setValue(SafeVaultBlock.FORMED, true)
|
||||
.setValue(SafeVaultBlock.PART, formed.part(member)),
|
||||
Block.UPDATE_CLIENTS));
|
||||
if (player != null) player.sendSystemMessage(Component.translatable(
|
||||
"message.anotherworld.safe_vault.formed",
|
||||
formed.center().getX(), formed.center().getY(), formed.center().getZ()));
|
||||
return true;
|
||||
}
|
||||
if (player != null) player.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.already_owned"));
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isOperator(Player player) {
|
||||
return player instanceof ServerPlayer serverPlayer && serverPlayer.createCommandSourceStack().permissions()
|
||||
.hasPermission(Permissions.COMMANDS_GAMEMASTER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.entity.SafeVaultBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.block.SafeVaultBlock;
|
||||
import fr.koka99cab.sanctuary26.anotherworld.registry.AnotherWorldBlocks;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Detects one isolated, hollow 3x3x3 vault cube owned by a single player. */
|
||||
public final class VaultStructure {
|
||||
private VaultStructure() {
|
||||
}
|
||||
|
||||
public static Optional<Formed> find(Level level, BlockPos member) {
|
||||
if (level == null || member == null || !level.getBlockState(member).is(AnotherWorldBlocks.SAFE_BLOCK)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Direction preferred = level.getBlockState(member).getValue(SafeVaultBlock.FACING);
|
||||
for (Direction facing : List.of(preferred, preferred.getOpposite(),
|
||||
preferred.getClockWise(), preferred.getCounterClockWise())) {
|
||||
Direction width = facing.getClockWise();
|
||||
for (int offsetWidth = -VaultRules.STRUCTURE_RADIUS;
|
||||
offsetWidth <= VaultRules.STRUCTURE_RADIUS; offsetWidth++) {
|
||||
for (int offsetY = -VaultRules.STRUCTURE_RADIUS;
|
||||
offsetY <= VaultRules.STRUCTURE_RADIUS; offsetY++) {
|
||||
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 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 attached Safe Blocks: a vault is one discrete 3x3x3 cube.
|
||||
for (int widthOffset = -2; widthOffset <= 2; widthOffset++) {
|
||||
for (int yOffset = -2; yOffset <= 2; yOffset++) {
|
||||
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);
|
||||
}
|
||||
|
||||
public static BlockPos centerFor(BlockPos member, BlockState state) {
|
||||
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)
|
||||
.relative(state.getValue(SafeVaultBlock.FACING), VaultRules.STRUCTURE_RADIUS - part.depth())
|
||||
.immutable();
|
||||
}
|
||||
|
||||
public record Formed(BlockPos center, UUID owner, List<BlockPos> members, Direction facing) {
|
||||
public boolean contains(BlockPos pos) {
|
||||
return pos != null && members.contains(pos);
|
||||
}
|
||||
|
||||
public VaultPart part(BlockPos pos) {
|
||||
Direction width = facing.getClockWise();
|
||||
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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 446 B |
@@ -30,14 +30,15 @@
|
||||
"minecraft": "~26.2",
|
||||
"java": ">=25",
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"ambiance": "*"
|
||||
"ambiance": "*",
|
||||
"sanctuary": ">=0.0.0-alpha.131"
|
||||
},
|
||||
"custom": {
|
||||
"sanctuary26": {
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "anotherworld",
|
||||
"data_version": 1,
|
||||
"network_protocol": 1,
|
||||
"data_version": 5,
|
||||
"network_protocol": 2,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
"descriptions": {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class VaultRulesSmoke {
|
||||
private VaultRulesSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
require(VaultRules.STRUCTURE_WIDTH == 3, "vault width");
|
||||
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(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
|
||||
&& VaultRules.buttonAmount(2) == 1 && VaultRules.buttonAmount(3) == 64,
|
||||
"button transfer amounts");
|
||||
require(!VaultRules.buttonDeposits(1) && VaultRules.buttonDeposits(2),
|
||||
"button transfer directions");
|
||||
require(VaultRules.buttonCurrency(12) == -1 && VaultRules.buttonAmount(-1) == 0,
|
||||
"invalid button rejection");
|
||||
require(VaultRules.RAID_DURATION_TICKS == 1_200, "raid duration");
|
||||
require(VaultRules.RAID_PROTECTION_TICKS == 1_728_000L, "raid protection");
|
||||
require(VaultRules.raidLoot(0) == 0, "empty loot");
|
||||
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_730L, "32-bit balance raid");
|
||||
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
|
||||
"64-bit balance raid without overflow");
|
||||
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
|
||||
"an attacker without a registered vault cannot receive raid loot");
|
||||
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
|
||||
"an attacker with a registered vault can receive raid loot");
|
||||
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
|
||||
"a vault owner cannot receive loot from their own vault");
|
||||
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
|
||||
"balance saturation without overflow");
|
||||
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
|
||||
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");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String contract) {
|
||||
if (!condition) throw new IllegalStateException("Invalid vault contract: " + contract);
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,23 @@ fabric_api_version=0.158.0+26.2
|
||||
# Standby modules stay at zero until their migration really begins.
|
||||
mod_version=0.0.0-alpha.0
|
||||
module_lifecycle=standby
|
||||
ambiance_version=0.0.0-alpha.11
|
||||
ambiance_version=0.0.0-alpha.22
|
||||
ambiance_lifecycle=active
|
||||
redstoner_version=0.0.0-alpha.10
|
||||
redstoner_version=0.0.0-alpha.13
|
||||
redstoner_lifecycle=active
|
||||
anotherworld_version=0.0.0-alpha.49
|
||||
anotherworld_version=0.0.0-alpha.57
|
||||
anotherworld_lifecycle=active
|
||||
itsalive_version=0.0.0-alpha.30
|
||||
itsalive_version=0.0.0-alpha.38
|
||||
itsalive_lifecycle=active
|
||||
ouch_version=0.0.0-alpha.8
|
||||
ouch_version=0.0.0-alpha.11
|
||||
ouch_lifecycle=active
|
||||
masterkey_version=0.0.0-alpha.8
|
||||
masterkey_version=0.0.0-alpha.9
|
||||
masterkey_lifecycle=active
|
||||
iliketomoveit_version=0.0.0-alpha.13
|
||||
iliketomoveit_version=0.0.0-alpha.18
|
||||
iliketomoveit_lifecycle=active
|
||||
onlyfun_version=0.0.0-alpha.19
|
||||
onlyfun_version=0.0.0-alpha.27
|
||||
onlyfun_lifecycle=active
|
||||
sanctuary_version=0.0.0-alpha.114
|
||||
sanctuary_version=0.0.0-alpha.131
|
||||
sanctuary_lifecycle=active
|
||||
pack_version=26.2.0-alpha.198
|
||||
pack_version=26.2.0-alpha.226
|
||||
maven_group=fr.koka99cab.sanctuary26
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Magic Carpet et Totem de Notch
|
||||
|
||||
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
|
||||
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
|
||||
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
|
||||
catalogue et applique le jalon avant d'exposer l'offre.
|
||||
|
||||
## Identités et provenance
|
||||
|
||||
| Rôle | ID | Statut |
|
||||
|---|---|---|
|
||||
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
|
||||
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
|
||||
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
|
||||
|
||||
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
|
||||
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
|
||||
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
|
||||
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
|
||||
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
|
||||
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
|
||||
convertie une seconde fois.
|
||||
|
||||
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
|
||||
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
|
||||
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
|
||||
diagnostic et à l'infobulle.
|
||||
|
||||
## Première récompense
|
||||
|
||||
Après la première victoire mondiale, Sanctuary réserve atomiquement la
|
||||
récompense puis I Like To Move It crée un coffre déterministe près de la maison
|
||||
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
|
||||
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
|
||||
créé ce coffre.
|
||||
|
||||
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
|
||||
Une reprise entre les deux relance la création de façon idempotente ; un coffre
|
||||
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
|
||||
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
|
||||
l'état livré afin de ne rien dupliquer.
|
||||
|
||||
## Plateforme temporaire
|
||||
|
||||
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
|
||||
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
|
||||
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
|
||||
dégât de chute est réinitialisé tant que la plateforme est active.
|
||||
|
||||
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
|
||||
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
|
||||
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
|
||||
changement de dimension et pendant l'arrêt normal du serveur.
|
||||
|
||||
## Remplacement au Shop
|
||||
|
||||
Only Fun associe le Magic Carpet au resolver public
|
||||
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
|
||||
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
|
||||
Après la victoire, elle rejoint la rotation normale au prix configuré de
|
||||
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
|
||||
paient chaque remplacement et la Mailbox effectue la livraison différée.
|
||||
|
||||
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
|
||||
et ne touche jamais aux états de réservation ou de coffre de la récompense
|
||||
initiale.
|
||||
@@ -29,7 +29,7 @@ Une route est un JSON sous `data/<namespace>/sanctuary_portal/<id>.json` :
|
||||
- `arrival.type` vaut `surface`, `matched_y` ou `fixed_y`; ce dernier exige `arrival.y`.
|
||||
- `priority` départage deux routes acceptant le même cadre.
|
||||
|
||||
Les neuf routes historiques restent déclarées pour la compatibilité. Pour l'alpha.150, seules deux routes sont actives : terre vers la paire Sanctuary/Overworld et pierre vers `sanctuary:caverns`. Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
|
||||
Les neuf routes historiques restent déclarées pour la compatibilité. Depuis l'alpha.219, trois routes sont actives : terre vers la paire Sanctuary/Overworld, pierre vers `sanctuary:caverns` et cobblestone moussue vers `sanctuary:alpha`. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
|
||||
|
||||
La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, elle relie `minecraft:overworld` à `sanctuary:overworld`. Dans le profil Overworld, elle relie `minecraft:overworld` à `sanctuary:main`. Le choix appartient à la sauvegarde, jamais à une préférence cliente.
|
||||
|
||||
@@ -43,8 +43,16 @@ La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, ell
|
||||
4. Reconstruire le même cadre réactive son endpoint; la traversée suivante régénère un lien.
|
||||
5. Une destination absente refuse la traversée proprement. Elle ne crée aucune dimension et n'endommage pas la sauvegarde.
|
||||
|
||||
La route Alpha exige un cadre extérieur de 4 × 5 avec un intérieur vide de
|
||||
2 × 3, entièrement en cobblestone moussue, puis un briquet. Le serveur valide
|
||||
le cadre, l'activateur et la présence de `sanctuary:alpha` avant de l'allumer.
|
||||
Sa première moitié distante utilise l'arrivée sûre calculée par l'API publique
|
||||
de Sanctuary, hors de la maison Indev ; le client ne choisit ni la destination
|
||||
ni les coordonnées. L'Atlas découvre le bloc de portail actif comme les autres
|
||||
points d'intérêt locaux.
|
||||
|
||||
Le bloc intérieur actif est `iliketomoveit:portal`. Les neuf anciens blocs `portals:*_portal` restent enregistrés comme compatibilité de lecture; une première utilisation les rattache au nouvel index.
|
||||
|
||||
## État des générateurs
|
||||
|
||||
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns, Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Leurs cadres peuvent être allumés et persistés ; une traversée vers une destination encore absente est refusée proprement.
|
||||
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns et Alpha sont également générés ; Alpha est le monde Indev fini 128 × 128 × 64 possédé par Sanctuary. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Une traversée vers une destination absente est refusée proprement dès l'allumage.
|
||||
|
||||
@@ -8,8 +8,40 @@ tasks.register("grapplePhysicsSmoke", JavaExec) {
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.register("waystoneElevatorRulesSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks directional discovery rules for direct Waystone elevator travel."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorRulesSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.register("happyGhastCruiseSpeedSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks the rider-only Happy Ghast cruise-speed multiplier."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeedSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.register("alphaPortalContractSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks the active Alpha route and its finite-world safe-arrival contract."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.portal.AlphaPortalContractSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.register("magicCarpetLedgerSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
// The physics contract is an executable smoke task, not a JUnit engine test.
|
||||
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
@@ -46,6 +78,10 @@ tasks.register("verifyWaystones") {
|
||||
'event.key() == 82',
|
||||
'new WaystoneRenamePayload(source, name)',
|
||||
'class WaystoneElevatorController',
|
||||
'class WaystoneElevatorRules',
|
||||
'canUseDirectDestination(upward',
|
||||
'canTeleport(elevatorRide, source.getY(), target.getY()',
|
||||
'discoveries.discover(player.getUUID(), level, target)',
|
||||
'player.isShiftKeyDown()',
|
||||
'player.getDeltaMovement().y > JUMP_VELOCITY_THRESHOLD',
|
||||
'useElevatorControl(player, supportingStone, false)',
|
||||
@@ -72,11 +108,11 @@ tasks.register("verifyWaystones") {
|
||||
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
|
||||
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
if (manifest.custom?.sanctuary26?.data_version != 3
|
||||
if (manifest.custom?.sanctuary26?.data_version != 4
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 5
|
||||
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|
||||
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
|
||||
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|
||||
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
|
||||
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
|
||||
}
|
||||
["fr_fr", "en_us", "ru_ru"].each { language ->
|
||||
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
|
||||
@@ -156,6 +192,7 @@ tasks.register("verifyGrappling") {
|
||||
"data/iliketomoveit/loot_table/blocks/pulley_head.json",
|
||||
"data/spiderman/loot_table/blocks/pulley_head.json",
|
||||
"data/minecraft/tags/block/needs_stone_tool.json",
|
||||
"iliketomoveit.mixins.json",
|
||||
"iliketomoveit.client.mixins.json"
|
||||
]
|
||||
def missingResources = requiredResources.findAll { !file("src/main/resources/${it}").isFile() }
|
||||
@@ -172,10 +209,11 @@ tasks.register("verifyGrappling") {
|
||||
}
|
||||
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
if (!manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|
||||
|| manifest.custom?.sanctuary26?.data_version != 3
|
||||
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|
||||
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|
||||
|| manifest.custom?.sanctuary26?.data_version != 4
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
|
||||
throw new GradleException("Grappling must keep I Like To Move It aggregate data v3/protocol 5 and register its client mixin")
|
||||
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
|
||||
}
|
||||
["fr_fr", "en_us", "ru_ru"].each { language ->
|
||||
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
|
||||
@@ -246,7 +284,7 @@ tasks.register("verifyPortals") {
|
||||
def routeFile = file("src/main/resources/data/iliketomoveit/sanctuary_portal/${route}.json")
|
||||
if (!routeFile.isFile()) throw new GradleException("Missing portal route ${route}")
|
||||
def json = new JsonSlurper().parse(routeFile)
|
||||
def expectedSchema = route == 'overworld' ? 2 : 1
|
||||
def expectedSchema = route in ['overworld', 'alpha'] ? 2 : 1
|
||||
if (json.schema_version != expectedSchema || json.destination_dimension != destination
|
||||
|| json.source_dimension != "minecraft:overworld"
|
||||
|| (route == 'overworld' && json.destination_resolver != "overworld_pair")
|
||||
@@ -283,7 +321,7 @@ tasks.register("verifyPortals") {
|
||||
}
|
||||
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.13"
|
||||
if (project.version.toString() != "0.0.0-alpha.18"
|
||||
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|
||||
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|
||||
|| atlasCavernsMigration.portals?.active_routes != [
|
||||
@@ -291,7 +329,7 @@ tasks.register("verifyPortals") {
|
||||
|| atlasCavernsMigration.portals?.inactive_routes?.size() != 7
|
||||
|| atlasCavernsMigration.portals?.reserved_ids_retained != true
|
||||
|| atlasCavernsMigration.portals?.inactive_routes_loadable != false
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS)")
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|
||||
|| !javaText.contains("REQUIRED_ROUTES = ACTIVE_ROUTES")
|
||||
|| atlasCavernsMigration.compatibility?.iliketomoveit_data_version != 3
|
||||
|| atlasCavernsMigration.compatibility?.iliketomoveit_network_protocol != 3) {
|
||||
@@ -319,7 +357,7 @@ tasks.register("verifySilverAircraft") {
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.13"
|
||||
if (project.version.toString() != "0.0.0-alpha.18"
|
||||
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|
||||
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
|
||||
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
|
||||
@@ -348,7 +386,7 @@ tasks.register("verifyGrapplePrediction") {
|
||||
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
|
||||
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
|
||||
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.13"
|
||||
if (project.version.toString() != "0.0.0-alpha.18"
|
||||
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|
||||
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|
||||
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
|
||||
@@ -365,11 +403,86 @@ tasks.register("verifyGrapplePrediction") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyWaystoneDescendingRelease") {
|
||||
group = "verification"
|
||||
description = "Checks alpha.14 downward discovery for direct Waystone elevators."
|
||||
inputs.file(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.201-waystone-shop-culinary-to-alpha.202.json"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java"))
|
||||
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRulesSmoke.java"))
|
||||
doLast {
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.201-waystone-shop-culinary-to-alpha.202.json"))
|
||||
def rules = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
|
||||
def teleportation = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.18"
|
||||
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|
||||
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|
||||
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
|
||||
|| migration.waystone_elevator?.undiscovered_upward_destination_allowed != false
|
||||
|| migration.waystone_elevator?.undiscovered_ordinary_destination_allowed != false
|
||||
|| migration.compatibility?.iliketomoveit_data_version != 3
|
||||
|| migration.compatibility?.iliketomoveit_network_protocol != 5
|
||||
|| !rules.contains("return !upward || targetDiscovered;")
|
||||
|| !rules.contains("elevatorRide && targetY < sourceY")
|
||||
|| !teleportation.contains("discoveries.discover(player.getUUID(), level, target)")) {
|
||||
throw new GradleException("I Like To Move It alpha.14 downward Waystone discovery contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyAlphaPortal") {
|
||||
group = "verification"
|
||||
description = "Checks the alpha.16 mossy Alpha portal lifecycle and server authority."
|
||||
inputs.file(file("PORTALS.md"))
|
||||
inputs.file(file("src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
|
||||
inputs.files(fileTree("src/main/resources/assets/iliketomoveit/lang") { include "*.json" })
|
||||
inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal") { include "*.java" })
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
doLast {
|
||||
def route = new JsonSlurper().parse(file(
|
||||
"src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
def javaText = fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal")
|
||||
.matching { include "*.java" }.files*.text.join("\n")
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.18"
|
||||
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|
||||
|| route.destination_dimension != "sanctuary:alpha"
|
||||
|| route.destination_resolver != "fixed"
|
||||
|| route.frame_blocks != ["minecraft:mossy_cobblestone"]
|
||||
|| route.activation_items != ["minecraft:flint_and_steel"]
|
||||
|| migration.alpha_portal?.outer_width != 4 || migration.alpha_portal?.outer_height != 5
|
||||
|| migration.alpha_portal?.inner_width != 2 || migration.alpha_portal?.inner_height != 3
|
||||
|| migration.alpha_portal?.inactive_when_destination_absent != true
|
||||
|| migration.alpha_portal?.persistent_pair_reused != true
|
||||
|| migration.alpha_portal?.break_unlinks_pair != true
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|
||||
|| !javaText.contains("SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed())")
|
||||
|| !javaText.contains("getServer().getLevel(")
|
||||
|| locales.any { !it.containsKey("message.iliketomoveit.portal.destination_unavailable") }) {
|
||||
throw new GradleException("I Like To Move It alpha.16 Alpha portal contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("grapplePhysicsSmoke")
|
||||
dependsOn tasks.named("magicCarpetLedgerSmoke")
|
||||
dependsOn tasks.named("waystoneElevatorRulesSmoke")
|
||||
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
|
||||
dependsOn tasks.named("verifyWaystones")
|
||||
dependsOn tasks.named("verifyGrappling")
|
||||
dependsOn tasks.named("verifyGrapplePrediction")
|
||||
dependsOn tasks.named("verifyWaystoneDescendingRelease")
|
||||
dependsOn tasks.named("verifyPortals")
|
||||
dependsOn tasks.named("verifySilverAircraft")
|
||||
dependsOn tasks.named("alphaPortalContractSmoke")
|
||||
dependsOn tasks.named("verifyAlphaPortal")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
|
||||
public final class ILikeToMoveItMod implements ModInitializer {
|
||||
public static final String MOD_ID = "iliketomoveit";
|
||||
public static final String DISPLAY_NAME = "I Like To Move It";
|
||||
public static final int CURRENT_DATA_VERSION = 3;
|
||||
public static final int CURRENT_DATA_VERSION = 4;
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
public ILikeToMoveItMod() {
|
||||
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
|
||||
GrappleController.register();
|
||||
PulleyHeadController.register();
|
||||
MagicCarpetController.register();
|
||||
MagicCarpetMigrationService.register();
|
||||
NotchCarpetRewardService.register();
|
||||
WaystoneElevatorController.initialize();
|
||||
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
|
||||
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.item;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
|
||||
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
|
||||
public final class MagicCarpetItem extends Item {
|
||||
public MagicCarpetItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
var origin = SanctuaryItemProvenance.origin(stack);
|
||||
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
|
||||
.withStyle(ChatFormatting.GOLD));
|
||||
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
|
||||
.withStyle(ChatFormatting.AQUA));
|
||||
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
|
||||
.withStyle(ChatFormatting.DARK_PURPLE));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFoil(ItemStack stack) {
|
||||
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|
||||
|| super.isFoil(stack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.HappyGhastCruiseSpeed;
|
||||
import net.minecraft.world.entity.animal.happyghast.HappyGhast;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(HappyGhast.class)
|
||||
public abstract class HappyGhastMixin {
|
||||
@Inject(method = "getRiddenInput", at = @At("RETURN"), cancellable = true)
|
||||
private void iliketomoveit$boostCruiseSpeed(Player rider, Vec3 input,
|
||||
CallbackInfoReturnable<Vec3> callback) {
|
||||
callback.setReturnValue(HappyGhastCruiseSpeed.boost(callback.getReturnValue()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
|
||||
@Mixin(BaseContainerBlockEntity.class)
|
||||
abstract class MagicCarpetContainerMixin {
|
||||
@Inject(method = "loadAdditional", at = @At("TAIL"))
|
||||
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
|
||||
MagicCarpetMigration.migrateContainer((Container) (Object) this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Converts historical Totems already present as loaded item entities. */
|
||||
@Mixin(ItemEntity.class)
|
||||
abstract class MagicCarpetItemEntityMixin {
|
||||
@Inject(method = "tick", at = @At("HEAD"))
|
||||
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
|
||||
ItemEntity self = (ItemEntity) (Object) this;
|
||||
ItemStack current = self.getItem();
|
||||
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
|
||||
if (replacement != current) self.setItem(replacement);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.network;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneEntry;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneDiscoveryData;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorRules;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneSavedData;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryUnlock;
|
||||
@@ -112,16 +113,17 @@ public final class WaystoneTeleportation {
|
||||
WaystoneSyncService.syncNow(player);
|
||||
return;
|
||||
}
|
||||
if (!discoveries.knows(player.getUUID(), level, source)
|
||||
|| !discoveries.knows(player.getUUID(), level, target)) {
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.waystone.undiscovered"));
|
||||
return;
|
||||
}
|
||||
|
||||
List<WaystoneEntry> entries = data.entriesFor(level);
|
||||
boolean sourceElevator = isElevatorPos(source, entries);
|
||||
boolean targetElevator = isElevatorPos(target, entries);
|
||||
boolean elevatorRide = sourceElevator && targetElevator && isElevatorRide(source, target);
|
||||
boolean sourceDiscovered = discoveries.knows(player.getUUID(), level, source);
|
||||
boolean targetDiscovered = discoveries.knows(player.getUUID(), level, target);
|
||||
if (!WaystoneElevatorRules.canTeleport(elevatorRide, source.getY(), target.getY(),
|
||||
sourceDiscovered, targetDiscovered)) {
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.waystone.undiscovered"));
|
||||
return;
|
||||
}
|
||||
if ((sourceElevator || targetElevator) && !elevatorRide) {
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.waystone.elevator_only"));
|
||||
return;
|
||||
@@ -148,6 +150,7 @@ public final class WaystoneTeleportation {
|
||||
player.setDeltaMovement(Vec3.ZERO);
|
||||
player.resetFallDistance();
|
||||
spawnWarpEffect(level, feet);
|
||||
if (discoveries.discover(player.getUUID(), level, target)) WaystoneSyncService.syncNow(player);
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.waystone.teleported", cost));
|
||||
}
|
||||
|
||||
@@ -190,7 +193,8 @@ public final class WaystoneTeleportation {
|
||||
discoveries.discover(player.getUUID(), level, source);
|
||||
Optional<BlockPos> target = data.entriesFor(level).stream()
|
||||
.map(WaystoneEntry::pos)
|
||||
.filter(pos -> discoveries.knows(player.getUUID(), level, pos))
|
||||
.filter(pos -> WaystoneElevatorRules.canUseDirectDestination(upward,
|
||||
discoveries.knows(player.getUUID(), level, pos)))
|
||||
.filter(pos -> pos.getX() == source.getX() && pos.getZ() == source.getZ())
|
||||
.filter(pos -> upward ? pos.getY() > source.getY() : pos.getY() < source.getY())
|
||||
.filter(pos -> isWaystone(level, pos))
|
||||
|
||||
@@ -26,7 +26,7 @@ public final class PortalDefinitions {
|
||||
public static final Identifier LAPUTA = ILikeToMoveItMod.id("laputa");
|
||||
|
||||
/** Routes enabled for the current gameplay milestone. Other stable IDs stay reserved. */
|
||||
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS);
|
||||
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA);
|
||||
public static final List<Identifier> REQUIRED_ROUTES = ACTIVE_ROUTES;
|
||||
|
||||
private static volatile Map<Identifier, PortalDefinition> definitions = Map.of();
|
||||
|
||||
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
|
||||
import java.util.Optional;
|
||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
@@ -37,6 +38,12 @@ public final class PortalIgnitionHandler {
|
||||
}
|
||||
|
||||
ServerLevel serverLevel = (ServerLevel) level;
|
||||
if (serverLevel.getServer().getLevel(
|
||||
selected.targetDimension(serverLevel.getServer(), serverLevel.dimension())) == null) {
|
||||
player.sendOverlayMessage(Component.translatable(
|
||||
"message.iliketomoveit.portal.destination_unavailable"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
PortalEndpoint endpoint = PortalSavedData.get(serverLevel.getServer())
|
||||
.register(serverLevel, selectedFrame, selected.id());
|
||||
selectedFrame.fill(serverLevel, selected, endpoint, ILikeToMoveItBlocks.PORTAL);
|
||||
|
||||
@@ -3,6 +3,8 @@ package fr.koka99cab.sanctuary26.iliketomoveit.portal;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.block.PortalBlock;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
@@ -92,6 +94,9 @@ public final class PortalTeleporter {
|
||||
|
||||
private static BlockPos targetCenter(ServerLevel sourceLevel, ServerLevel targetLevel,
|
||||
PortalFrame sourceFrame, PortalDefinition definition) {
|
||||
if (targetLevel.dimension().equals(SanctuaryDimensions.ALPHA)) {
|
||||
return SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed());
|
||||
}
|
||||
double scale = definition.scaleFrom(sourceLevel.dimension());
|
||||
int x = Mth.floor(sourceFrame.center().getX() * scale);
|
||||
int z = Mth.floor(sourceFrame.center().getZ() * scale);
|
||||
|
||||
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
|
||||
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
|
||||
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
|
||||
public static final Item MAGIC_CARPET = register("magic_carpet",
|
||||
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
|
||||
public static final Item LEGACY_NOTCH_TOTEM = register(
|
||||
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
|
||||
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
public static final Item GRAPPLING_HAND = register("grappling_hand",
|
||||
key -> new Item(new Item.Properties().setId(key)));
|
||||
public static final Item GRAPPLING_ROD = register("grappling_rod",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Keeps the Happy Ghast ride practical without changing its autonomous movement. */
|
||||
public final class HappyGhastCruiseSpeed {
|
||||
public static final double RIDER_INPUT_MULTIPLIER = 2.0D;
|
||||
|
||||
private HappyGhastCruiseSpeed() {
|
||||
}
|
||||
|
||||
public static Vec3 boost(Vec3 vanillaInput) {
|
||||
return vanillaInput.scale(RIDER_INPUT_MULTIPLIER);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
|
||||
private static final int RADIUS = 1;
|
||||
private static final Block CARPET_BLOCK = Blocks.GLASS;
|
||||
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
|
||||
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
|
||||
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
|
||||
|
||||
private MagicCarpetController() {
|
||||
}
|
||||
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
|
||||
connected.add(id);
|
||||
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
|
||||
if (active) {
|
||||
if (!ACTIVE_CARPETS.containsKey(id)) {
|
||||
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
|
||||
if (!ACTIVE_CARPETS.contains(id)) {
|
||||
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
|
||||
}
|
||||
updateCarpet(id, player);
|
||||
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
|
||||
deactivate(server, id, true);
|
||||
}
|
||||
}
|
||||
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<UUID, CarpetState> entry = iterator.next();
|
||||
if (!connected.contains(entry.getKey())) {
|
||||
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
|
||||
iterator.remove();
|
||||
for (UUID owner : ACTIVE_CARPETS.owners()) {
|
||||
if (!connected.contains(owner)) {
|
||||
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
|
||||
if (state != null) removePositions(server, owner, state, state.positions());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isMagicCarpet(ItemStack stack) {
|
||||
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
|
||||
return MagicCarpetMigration.isAnyCarpet(stack);
|
||||
}
|
||||
|
||||
private static void updateCarpet(UUID owner, ServerPlayer player) {
|
||||
ServerLevel level = player.level();
|
||||
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
|
||||
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
|
||||
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
|
||||
if (!previous.dimension().equals(level.dimension())) {
|
||||
removePositions(level.getServer(), owner, previous, previous.positions());
|
||||
previous = CarpetState.empty(level.dimension());
|
||||
ACTIVE_CARPETS.remove(owner);
|
||||
previous = ACTIVE_CARPETS.state(owner, level.dimension());
|
||||
} else {
|
||||
Set<BlockPos> stale = new HashSet<>(previous.positions());
|
||||
stale.removeAll(target);
|
||||
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
|
||||
placed.add(pos);
|
||||
}
|
||||
}
|
||||
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
|
||||
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
|
||||
player.resetFallDistance();
|
||||
}
|
||||
|
||||
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
|
||||
}
|
||||
|
||||
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
|
||||
CarpetState state = ACTIVE_CARPETS.remove(owner);
|
||||
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
|
||||
if (state == null) return;
|
||||
removePositions(server, owner, state, state.positions());
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(owner);
|
||||
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
|
||||
}
|
||||
|
||||
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
|
||||
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
|
||||
ServerLevel level = server.getLevel(state.dimension());
|
||||
if (level == null) return;
|
||||
for (BlockPos pos : positions) {
|
||||
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
|
||||
}
|
||||
|
||||
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
|
||||
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
|
||||
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
|
||||
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
|
||||
}
|
||||
|
||||
private static void reset(MinecraftServer server) {
|
||||
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
|
||||
ACTIVE_CARPETS.clear();
|
||||
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
|
||||
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
|
||||
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
|
||||
}
|
||||
|
||||
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
|
||||
public final class MagicCarpetLedger {
|
||||
private final Map<UUID, State> carpets = new HashMap<>();
|
||||
|
||||
public boolean contains(UUID owner) {
|
||||
return carpets.containsKey(owner);
|
||||
}
|
||||
|
||||
public State state(UUID owner, ResourceKey<Level> dimension) {
|
||||
return carpets.getOrDefault(owner, State.empty(dimension));
|
||||
}
|
||||
|
||||
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
|
||||
}
|
||||
|
||||
public State remove(UUID owner) {
|
||||
return carpets.remove(owner);
|
||||
}
|
||||
|
||||
public Set<UUID> owners() {
|
||||
return Set.copyOf(carpets.keySet());
|
||||
}
|
||||
|
||||
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
|
||||
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
|
||||
&& entry.getValue().dimension().equals(dimension)
|
||||
&& entry.getValue().positions().contains(pos));
|
||||
}
|
||||
|
||||
public Map<UUID, State> drain() {
|
||||
Map<UUID, State> copy = Map.copyOf(carpets);
|
||||
carpets.clear();
|
||||
return copy;
|
||||
}
|
||||
|
||||
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
public State {
|
||||
positions = Set.copyOf(positions);
|
||||
}
|
||||
|
||||
private static State empty(ResourceKey<Level> dimension) {
|
||||
return new State(dimension, Set.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
|
||||
public final class MagicCarpetMigration {
|
||||
private MagicCarpetMigration() {
|
||||
}
|
||||
|
||||
public static boolean isAnyCarpet(ItemStack stack) {
|
||||
return stack != null && !stack.isEmpty()
|
||||
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|
||||
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
|
||||
}
|
||||
|
||||
public static ItemStack migrateStack(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
|
||||
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
|
||||
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
public static int migrateContainer(Container container) {
|
||||
if (container == null) return 0;
|
||||
int migrated = 0;
|
||||
for (int slot = 0; slot < container.getContainerSize(); slot++) {
|
||||
ItemStack current = container.getItem(slot);
|
||||
ItemStack replacement = migrateStack(current);
|
||||
if (replacement == current) continue;
|
||||
container.setItem(slot, replacement);
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) container.setChanged();
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Converts online inventories once their historical alias has been decoded. */
|
||||
public final class MagicCarpetMigrationService {
|
||||
private MagicCarpetMigrationService() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
|
||||
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
|
||||
}
|
||||
|
||||
private static void tick(MinecraftServer server) {
|
||||
ILikeToMoveItData.get(server);
|
||||
if (server.overworld().getGameTime() % 20L != 0L) return;
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
|
||||
}
|
||||
|
||||
private static void migrate(ServerPlayer player) {
|
||||
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
|
||||
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
|
||||
if (migrated > 0) {
|
||||
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
|
||||
migrated, player.getGameProfile().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.item.component.FireworkExplosion;
|
||||
import net.minecraft.world.item.component.Fireworks;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
|
||||
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
|
||||
public final class NotchCarpetRewardService {
|
||||
private static final int REWARD_SLOT = 13;
|
||||
|
||||
private NotchCarpetRewardService() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
|
||||
}
|
||||
|
||||
private static void tick(MinecraftServer server) {
|
||||
if (server.overworld().getGameTime() % 20L != 0L) return;
|
||||
var state = SanctuaryNotchMilestone.snapshot(server);
|
||||
if (!state.defeated() || state.initialRewardDelivered()) return;
|
||||
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
|
||||
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
|
||||
if (alpha == null) return;
|
||||
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
|
||||
if (!alpha.isLoaded(home)) return;
|
||||
RewardChest result = ensureRewardChest(alpha, home);
|
||||
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
|
||||
if (result.created()) {
|
||||
launchFirework(alpha, result.pos());
|
||||
server.getPlayerList().broadcastSystemMessage(
|
||||
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
|
||||
}
|
||||
ILikeToMoveItMod.LOGGER.info(
|
||||
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
|
||||
result.pos().toShortString(), !result.created());
|
||||
}
|
||||
|
||||
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
|
||||
List<BlockPos> candidates = candidates(home);
|
||||
for (BlockPos pos : candidates) {
|
||||
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
|
||||
}
|
||||
for (BlockPos pos : candidates) {
|
||||
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|
||||
|| level.getBlockState(pos.below()).isAir()) continue;
|
||||
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
|
||||
chest.setItem(REWARD_SLOT, rewardStack());
|
||||
chest.setChanged();
|
||||
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
|
||||
return new RewardChest(pos, true);
|
||||
}
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
|
||||
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|
||||
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
|
||||
for (ItemStack stack : container) {
|
||||
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|
||||
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ItemStack rewardStack() {
|
||||
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
|
||||
reward.set(DataComponents.CUSTOM_NAME,
|
||||
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
|
||||
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
|
||||
return reward;
|
||||
}
|
||||
|
||||
private static List<BlockPos> candidates(BlockPos home) {
|
||||
ArrayList<BlockPos> positions = new ArrayList<>();
|
||||
for (int y : new int[] {0, 1, -1, 2}) {
|
||||
for (int radius = 2; radius <= 7; radius++) {
|
||||
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
|
||||
BlockPos candidate = home.offset(x, y, z).immutable();
|
||||
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(positions);
|
||||
}
|
||||
|
||||
private static void launchFirework(ServerLevel level, BlockPos pos) {
|
||||
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
|
||||
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
|
||||
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
|
||||
new IntArrayList(new int[] {0x9B59B6}), true, true);
|
||||
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
|
||||
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
|
||||
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
|
||||
}
|
||||
|
||||
private record RewardChest(BlockPos pos, boolean created) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.waystone;
|
||||
|
||||
/** Directional discovery rules shared by direct controls and authoritative teleport validation. */
|
||||
public final class WaystoneElevatorRules {
|
||||
private WaystoneElevatorRules() {
|
||||
}
|
||||
|
||||
/** Descending may discover the next floor; ascending still requires prior discovery. */
|
||||
public static boolean canUseDirectDestination(boolean upward, boolean targetDiscovered) {
|
||||
return !upward || targetDiscovered;
|
||||
}
|
||||
|
||||
/** Only a real downward elevator ride may bypass discovery of its target. */
|
||||
public static boolean canTeleport(boolean elevatorRide, int sourceY, int targetY,
|
||||
boolean sourceDiscovered, boolean targetDiscovered) {
|
||||
return sourceDiscovered && (targetDiscovered || elevatorRide && targetY < sourceY);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Aircraft",
|
||||
"item.iliketomoveit.magic_cloud": "Magic Cloud",
|
||||
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
|
||||
"item.iliketomoveit.magic_carpet": "Magic Carpet",
|
||||
"item.portals.notch_totem": "Notch Totem (legacy)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
|
||||
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
|
||||
"block.iliketomoveit.waystone": "Waystone",
|
||||
"block.waystone.waystone": "Waystone (legacy)",
|
||||
"entity.iliketomoveit.aircraft": "Aircraft",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
|
||||
"screen.iliketomoveit.waystone.title": "Waystones",
|
||||
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Avion",
|
||||
"item.iliketomoveit.magic_cloud": "Nuage magique",
|
||||
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
|
||||
"item.iliketomoveit.magic_carpet": "Tapis magique",
|
||||
"item.portals.notch_totem": "Totem de Notch (hérité)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
|
||||
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
|
||||
"block.iliketomoveit.waystone": "Pierre de voyage",
|
||||
"block.waystone.waystone": "Pierre de voyage (héritée)",
|
||||
"entity.iliketomoveit.aircraft": "Avion",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
|
||||
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
|
||||
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Самолёт",
|
||||
"item.iliketomoveit.magic_cloud": "Волшебное облако",
|
||||
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
|
||||
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
|
||||
"item.portals.notch_totem": "Тотем Нотча (старый)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
|
||||
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
|
||||
"block.iliketomoveit.waystone": "Камень пути",
|
||||
"block.waystone.waystone": "Камень пути (старый)",
|
||||
"entity.iliketomoveit.aircraft": "Самолёт",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
|
||||
"screen.iliketomoveit.waystone.title": "Камни пути",
|
||||
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
|
||||
|
||||