Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9791d178ab | ||
|
|
32158e2e05 | ||
|
|
3bfb3cb251 | ||
|
|
0eb1a381d4 | ||
|
|
b97ec6c9e8 | ||
|
|
448e2febff | ||
|
|
1c23d0c17e | ||
|
|
6f871446bb | ||
|
|
96a36d99be |
@@ -11,6 +11,7 @@ La vision est dans `docs/vision.md` ; elle décrit aussi des fonctionnalités fu
|
||||
- Ne pas modifier un monde existant, régénérer des chunks, changer un format de sauvegarde ou activer une expansion sans contrat de migration explicite.
|
||||
- Garder les identifiants `sanctuary:*` stables et documenter toute évolution de la génération avec graine et version.
|
||||
- Ajouter les libellés FR/EN des nouvelles interfaces. Vérifier les dépendances pour la version Minecraft exacte ; aucune compatibilité supposée à partir du nom d'un mod.
|
||||
- Les tests graphiques Sanctuary ciblent uniquement Vulkan. OpenGL est abandonné comme cible de validation depuis le 18 septembre 2026 : ne plus lancer de suite OpenGL ni revendiquer sa prise en charge à partir des essais historiques.
|
||||
- Lancer `./gradlew check build` pour livrer du code, et `./gradlew assemblePack` si la distribution change. Ajouter seulement les tests utiles au comportement touché.
|
||||
- Pour chaque nouvelle livraison du mod, incrémenter le compteur `beta.xxx` (départ `beta.001`) dans `mod_version` et `pack_version` de `gradle.properties`, synchroniser `packwiz/pack.toml` et documenter le changement. Le tag reprend cette version exacte, sans préfixe `v` ; voir `docs/versioning.md`. Une simple modification documentaire n'incrémente pas les binaires.
|
||||
- Le pack Beta suit un canal packwiz stable et une seule instance Prism. Pour une mise à jour demandée, suivre `docs/packwiz.md` : publier un artefact vérifié et immuable, avancer le canal, puis synchroniser l'instance existante en conservant ses sauvegardes et réglages.
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# Sanctuary
|
||||
|
||||
## beta.157 — photo obligatoire dans la Gazette (pack local)
|
||||
|
||||
Galerie de captures défilante, aperçu et assignation à un article. Publication
|
||||
avec photo obligatoire, stockage fichier ou MariaDB partagé avec le site.
|
||||
[Contrat de migration et vérifications](docs/gazette-photos-beta157.md).
|
||||
|
||||
## beta.156 — reprendre la partie sous la colonne communautaire (pack local)
|
||||
|
||||
Le bouton Reprendre la partie est aligné sous la Gazette et le tableau
|
||||
d’affichage, en bas à droite. [Vérifications](docs/pause-resume-beta156.md).
|
||||
|
||||
## beta.155 — nouvelle structure du menu pause (pack local)
|
||||
|
||||
Navigation Sanctuary à gauche, carte centrale, Gazette et demandes défilantes
|
||||
à droite. Date, intendance et heure en en-tête ; options et sortie en bas de la colonne
|
||||
gauche, reprise de la partie en bas au centre.
|
||||
[Contrat et vérifications](docs/pause-redesign-beta155.md).
|
||||
|
||||
## beta.154 — Gazette et tableau communautaire (pack local)
|
||||
|
||||
Panneaux gauche/droite du menu pause, intendance, publication, réponses,
|
||||
modification et modération. Stockage fichier par défaut ; MariaDB optionnelle
|
||||
commune avec le site. Shaders beta.151 conservés.
|
||||
[Contrat et configuration](docs/community-contract-v1.md) ·
|
||||
[Vérifications et limites](docs/community-beta154.md).
|
||||
|
||||
## beta.151 — Nether, reflets des blocs et SSR (pack local)
|
||||
|
||||
Synchronisation de l’inventaire au changement de dimension, retour de l’intensité
|
||||
spéculaire des blocs solides à son niveau antérieur et bois mat avec relief.
|
||||
SSR activé par défaut à 20 %, choix sauvegardés conservés.
|
||||
[Contrat et vérifications](docs/nether-pbr-beta151.md).
|
||||
|
||||
## beta.150 — Terre mate et reflets du coucher de soleil (pack local)
|
||||
|
||||
Terre mate avec relief conservé. Le soleil et son reflet se colorent ensemble
|
||||
en suivant la couleur et la transition du coucher de soleil natif de Minecraft.
|
||||
Les accents lumineux suivent les normales animées des vaguelettes et leur
|
||||
orientation vers le soleil et la caméra. [Contrat et vérifications](docs/matte-dirt-beta150.md).
|
||||
|
||||
## beta.149 — Distances PBR / SSR et feuillage (pack local)
|
||||
|
||||
Deux distances séparées, 64 blocs par défaut chacune, jusqu’à 256 blocs.
|
||||
Option renommée « SSR » et suppression du voile spéculaire sur le feuillage.
|
||||
[Contrat et vérifications](docs/independent-distances-beta149.md).
|
||||
|
||||
## beta.148 — PBR 50 % et soleil diffus (pack local)
|
||||
|
||||
PBR à 50 %, reflet solaire plus doux avec fusion par texel préservant
|
||||
la texture de l’eau. SSR à 20 %, portée commune jusqu’à 256 blocs.
|
||||
[Contrat et vérifications](docs/pbr-soft-sun-beta148.md).
|
||||
|
||||
## beta.147 — PBR proche, lune et portée 256 (pack local)
|
||||
|
||||
Correction du scintillement à très courte distance, reflet lunaire adouci
|
||||
et distance commune PBR/SSR jusqu’à 256 blocs.
|
||||
[Contrat et vérifications](docs/pbr-close-range-beta147.md).
|
||||
|
||||
## beta.146 — Reflets et transitions de l’eau (pack local)
|
||||
|
||||
Correction du masquage des personnages en F5 et sélection stricte des surfaces PBR.
|
||||
@@ -2336,7 +2394,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
|
||||
| Fabric API | 0.160.5+26.3 |
|
||||
| Fabric Loom | 1.17.20 |
|
||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||
| Sanctuary / pack | beta.146 |
|
||||
| Sanctuary / pack | beta.151 |
|
||||
|
||||
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
|
||||
uniquement la bibliothèque standard et repère aussi une installation Python
|
||||
@@ -2365,7 +2423,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.146.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.151.jar` : mod à installer avec
|
||||
Fabric API sur la version Minecraft indiquée.
|
||||
- `build/packwiz/` : pack de développement complet, avec le mod construit et
|
||||
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
|
||||
|
||||
@@ -136,3 +136,10 @@ intégré et le template personnel (SHA-256
|
||||
`a97570908db75caf8e6fc2f5ecabf12c54fd99bfea7be634df66c4310dca22af`).
|
||||
Le template regroupe les ressources client déjà distribuées par Sanctuary ;
|
||||
leurs crédits et conditions respectives restent applicables.
|
||||
|
||||
MariaDB Connector/J 3.5.10 (`org.mariadb.jdbc:mariadb-java-client`),
|
||||
MariaDB Corporation et contributeurs : LGPL-2.1-or-later. Le JAR officiel est
|
||||
embarqué comme dépendance imbriquée, sans modification, pour le stockage
|
||||
communautaire optionnel. Sa licence est fournie dans `licenses/mariadb-connector-j-LGPL-2.1.txt` du JAR Sanctuary.
|
||||
Sources correspondantes : https://github.com/mariadb-corporation/mariadb-connector-j/tree/3.5.10
|
||||
(distribution Maven Central 3.5.10).
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Backlog Sanctuary
|
||||
|
||||
## COMM-154 — Communauté dans le menu pause et sur le site
|
||||
|
||||
Branches `codex/community-beta154` (mod) et `codex/community-contract` (site).
|
||||
Résultat : Gazette, tableau et intendance avec lecture, publication, réponses,
|
||||
droits serveur et comptes liés ; fichier autonome ou MariaDB partagée.
|
||||
[Contrat](community-contract-v1.md), [livraison locale](community-beta154.md).
|
||||
|
||||
## STAT-03 — Atelier d’argile et hotbar — beta.112
|
||||
|
||||
Branche `codex/clay-workshop-hotbar-beta112`. [Contrat et vérifications](clay-workshop-hotbar-beta112.md).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# COMM-154 — Communauté et conversations, beta.154
|
||||
|
||||
Livraison locale préparée sur `codex/community-beta154`, depuis beta.151
|
||||
`b97ec6c9e820bed7693e75f200a02b16af01cf89`. Le site est préparé sur
|
||||
`codex/community-contract`, depuis `4af93d1e42ba6084ecd654967ad6d8f317a5d3ef`.
|
||||
Contrat identique dans les deux dépôts : [communauté v1](community-contract-v1.md).
|
||||
|
||||
## Résultat
|
||||
|
||||
Gazette à gauche et tableau à droite du menu pause, intendance au-dessus ;
|
||||
accès compacts lorsque la largeur GUI ne permet pas trois colonnes.
|
||||
Lire, publier et répondre, modifier son texte, masquer et clôturer une annonce.
|
||||
Modération autorisée côté serveur et comptes Web liés par code confirmé en jeu.
|
||||
|
||||
Les discussions montrent les visages Minecraft, le pseudo, la date et chaque
|
||||
message dans un bloc de conversation. Les actions secondaires sont regroupées
|
||||
sous « ⋯ » ; la réponse se saisit directement sous le fil. Même présentation
|
||||
sur le site, avec rendu des visages par UUID via son prestataire existant.
|
||||
|
||||
Fichier JSON atomique par défaut ; MariaDB optionnelle partagée avec Laravel.
|
||||
Le serveur exécute la persistance hors du thread de jeu. Brouillons conservés
|
||||
en cas de refus ; aucun repli silencieux vers un fichier après une panne SQL.
|
||||
Aucun changement des shaders, de leurs réglages, de la génération ou des
|
||||
sauvegardes existantes. Le delta part exactement du shader beta.151.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Environnement : macOS Apple M1, Java Temurin 25, Minecraft 26.3,
|
||||
Fabric Loader 0.19.5, Fabric API 0.160.5+26.3, Vulkan/MoltenVK 1.4.2.
|
||||
Site : PHP 8.5.10, Laravel 13.32.0, PHPUnit 12.5.35. MariaDB locale 12.3.3,
|
||||
Connector/J 3.5.10 imbriqué dans le mod.
|
||||
|
||||
- Scénario de stockage commun réussi sur fichier et MariaDB : persistance,
|
||||
redémarrage, exact retry, conflits de révision, auteurs, opérateurs,
|
||||
fermeture/réouverture, masquage, pagination, texte Unicode et isolation.
|
||||
Codes expirés, consommés et tentative de réattribution d’identité refusés.
|
||||
`build/community-client-database-final.log`, marqueurs FILE_PASS et SQL_PASS.
|
||||
- Aller-retour réel Java → HTTP Laravel → Java réussi : article, création d’un
|
||||
code Web, liaison côté Java, modification Web et réponse relues par Java.
|
||||
`build/community-interop-{seed,claim,verify}.log` ; deux phases PHPUnit,
|
||||
3 puis 6 assertions. Migration Laravel sur les tables déjà créées par le SQL
|
||||
du mod réussie ; création depuis Laravel également testée par les tests HTTP.
|
||||
- Tests client Vulkan réussis en FR/EN, GUI 2/3 (panneaux et mode compact),
|
||||
publication, réponse intégrée, clôture, actions contextuelles, visages,
|
||||
brouillon refusé et réponse tardive après fermeture.
|
||||
Fichier : `build/community-conversation-client.log`.
|
||||
MariaDB : `build/community-conversation-database.log`, mode explicitement
|
||||
vérifié par le client. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`.
|
||||
- Panne SQL testée avec une adresse locale sans service : erreur visible,
|
||||
brouillon conservé, aucune création de fichier de repli.
|
||||
`build/community-client-outage.log`, marqueur COMMUNITY154_OUTAGE_PASS.
|
||||
- Web : **12 tests / 60 assertions** communautaires réussis, comprenant
|
||||
validation, identité non falsifiable, restrictions du staff, auteur,
|
||||
XSS/texte échappé, formulaires, pagination, compte lié et rejet CSRF réel.
|
||||
Pint réussi. Rendu des pages et visages vérifié dans le navigateur local,
|
||||
navigation accueil → discussion et FR/EN. Aucune erreur console applicative ;
|
||||
avertissement THREE.Clock hérité du panorama.
|
||||
- Suite Web complète : **55 réussites, 1 échec, 1 test optionnel ignoré** sur
|
||||
57. L’échec existant de PlayerModerationTest attend « Nommer admin », alors
|
||||
que ViewUser affiche déjà « Nommer staff » dans le commit de départ.
|
||||
- `./gradlew check build --continue` exécuté : **229/252 GameTests réussis**,
|
||||
**23 échecs strictement identiques à beta.151** (comparaison des identifiants).
|
||||
`build/community-check-build.log`, `build/community-server-failures.json`.
|
||||
Ce passage a aussi rencontré un `ClassNotFoundException` temporaire dans
|
||||
seasonal105Smoke pendant des compilations concurrentes de vérification.
|
||||
Le contrôle final est relancé seul, avec la tâche GameTests exclue (les 252 cas ont été exécutés lors du premier passage).
|
||||
|
||||
Le contrôle complet reste rouge : cette livraison ne prétend pas réparer les
|
||||
23 GameTests historiques ni l’ancienne assertion de libellé Web. Les parcours
|
||||
communautaires sont validés indépendamment.
|
||||
|
||||
## Rejouer les tests ciblés
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:community154Smoke
|
||||
./gradlew :sanctuary:runClientGameTest \
|
||||
-PsanctuaryClientTests=true -PsanctuaryQuickTests=true \
|
||||
-PsanctuaryCommunity154ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
|
||||
```
|
||||
|
||||
Pour le même scénario en base, préparer une MariaDB de développement vide
|
||||
avec `src/main/resources/community/schema-v1.sql`, puis définir
|
||||
`SANCTUARY_COMMUNITY_TEST_JDBC` (URL JDBC),
|
||||
`SANCTUARY_COMMUNITY_CLIENT_DATABASE=true` et
|
||||
`SANCTUARY_COMMUNITY_DB_PASSWORD`. Ces fixtures de développement utilisent
|
||||
`root` sans mot de passe, uniquement dans la MariaDB locale isolée de test.
|
||||
Ne jamais les lancer sur une base personnelle ou de production.
|
||||
|
||||
L’aller-retour utilise une autre base isolée, également migrée par Laravel :
|
||||
`community154Interop -PcommunityInteropPhase=seed`, puis CommunityInteropTest
|
||||
avec `SANCTUARY_COMMUNITY_INTEROP_PHASE=link`, Java `claim`, PHP `write`, Java
|
||||
`verify`. Les deux programmes reçoivent le même chemin absolu
|
||||
`SANCTUARY_COMMUNITY_INTEROP_FILE` vers un JSON dans un dossier ignoré.
|
||||
Le test PHP reçoit les paramètres DB_CONNECTION/DB_HOST/DB_PORT/DB_DATABASE
|
||||
pointant exclusivement vers cette base. Sans cette variable, il est ignoré.
|
||||
|
||||
## Limites de livraison
|
||||
|
||||
Code et artefacts locaux ; aucun déploiement Web, serveur personnel, canal
|
||||
packwiz ou instance Prism. Validation Windows et connexion Discord réelle sur
|
||||
le domaine de production à faire lors du déploiement. Le compte Web utilisé
|
||||
dans les essais est fictif, la liaison passe cependant par le vrai stockage.
|
||||
Pas de pièces jointes, de notification poussée ou d’import fichier → base.
|
||||
Les anciens JAR encore présents sont conservés.
|
||||
|
||||
## Artefacts vérifiés
|
||||
|
||||
Export packwiz réussi. ZIP, versions Minecraft/Fabric, JAR unique, pilote JDBC
|
||||
imbriqué, licence, absence des tests et égalité octet pour octet des 29 fichiers
|
||||
shader avec beta.151 vérifiés. Reçu : `build/community-artifacts.json`.
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.154.jar` — SHA-256 `07e7419cb3e814d59a6b6f130a1b05b4f65c2407a0ffbd00b194ba97614923ad`.
|
||||
- `build/Sanctuary-beta.154.mrpack` — SHA-256 `fbc007fe59777b0c52b30b6e461b09fe0636fb0b86d254b9e98952856b7f6c8a`.
|
||||
|
||||
Site : commit local `32d804a` sur `codex/community-contract`.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Communauté Sanctuary — contrat v1
|
||||
|
||||
> Depuis beta.157, le schéma actif est la v2 avec photo obligatoire pour les nouveaux
|
||||
> articles. Voir la section « Évolution v2 » en fin de document avant installation.
|
||||
|
||||
Ticket beta.154, branche `codex/community-beta154`, socle beta.151 `b97ec6c`.
|
||||
Ce contrat décrit le comportement cible et les échanges implémentés par ce ticket.
|
||||
La note de livraison indique séparément les vérifications effectivement réussies.
|
||||
Les shaders de beta.151 sont conservés sans modification.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Gazette (`article`), tableau (`notice`), intendance (`bulletin`) et réponses
|
||||
(`reply`). Contenus texte brut : titre 120 caractères Unicode, corps 4 000,
|
||||
réponse 1 000. Aucune interprétation HTML, Markdown ou commande Minecraft.
|
||||
Catégories stables : `info`, `work`, `need`, `event`. Photos et pièces jointes
|
||||
sont hors de cette première livraison.
|
||||
|
||||
La pause présente la Gazette à gauche, les actions natives au centre, le tableau
|
||||
à droite et l'intendance au-dessus. À faible largeur GUI, accès compacts vers
|
||||
les mêmes écrans. Publication et réponses disponibles dès cette version.
|
||||
|
||||
## Autorité et identité
|
||||
|
||||
Le client passe exclusivement par le serveur Minecraft. L'auteur vient de la
|
||||
session serveur, jamais d'un UUID fourni par le client. Chaque installation
|
||||
possède un `serverId` UUID stable, également configuré sur le site. Chaque
|
||||
requête SQL inclut ce serveur. Les sauvegardes du monde restent indépendantes.
|
||||
|
||||
Tous les joueurs connectés peuvent publier et répondre. L'auteur peut modifier
|
||||
ou masquer son contenu et clôturer/réouvrir son annonce. Un opérateur Minecraft
|
||||
ou membre du staff Web disposant de `users.moderate` peut masquer des contenus, épingler les publications et
|
||||
publier l'intendance ; il ne réécrit pas le texte d'un autre auteur.
|
||||
Le site exige un compte connecté lié à l'UUID Minecraft : code aléatoire à usage
|
||||
unique créé sur le site, valable 10 minutes, confirmé en jeu. Seul le hash SHA-256
|
||||
est conservé. La confirmation nécessite un serveur authentifié (`online-mode`).
|
||||
Une identité déjà liée ne peut être réattribuée automatiquement.
|
||||
|
||||
## Stockage
|
||||
|
||||
`config/sanctuary-community.json` sélectionne `file` (défaut) ou `database`.
|
||||
Le fichier du monde `data/sanctuary-community.json` est remplacé atomiquement
|
||||
après synchronisation disque. Une corruption ou modification externe est une
|
||||
erreur : aucune réinitialisation silencieuse.
|
||||
|
||||
En mode base, MariaDB utilise les tables `sanctuary_community_*`, utf8mb4,
|
||||
InnoDB. Les nouvelles tables sont additives. Le schéma SQL v1 livré peut être
|
||||
installé par l'administrateur ; Laravel possède la migration équivalente.
|
||||
Le mod vérifie la version et ne lance jamais de migration au démarrage.
|
||||
Une seule application du schéma suffit avant de démarrer les deux applications.
|
||||
Les évolutions futures auront une migration explicite commune.
|
||||
|
||||
Aucun basculement automatique base → fichier lors d'une panne. Les lectures en
|
||||
cache restent signalées comme anciennes ; une écriture n'est confirmée qu'après
|
||||
commit. Les brouillons restent côté client durant la session. Les requêtes SQL
|
||||
s'exécutent hors du thread de jeu, avec délais et file d'attente bornés.
|
||||
Le passage fichier ↔ base nécessite un export/import explicite ; changer le mode
|
||||
ne migre ni ne fusionne les données. La v1 ne fournit pas cet importateur.
|
||||
|
||||
## Enregistrements et concurrence
|
||||
|
||||
Les entrées portent : serveur, UUID, parent éventuel, type, catégorie, UUID et nom
|
||||
d'auteur, titre, corps, état (`open`, `closed`, `hidden`), épingle, révision,
|
||||
date de création et date de modification (millisecondes Unix UTC).
|
||||
Une réponse a un parent article/annonce du même serveur ; pas de réponses imbriquées.
|
||||
Masquer un parent masque également sa discussion dans toutes les lectures.
|
||||
|
||||
Chaque création utilise un UUID d'opération stable. La répétition exacte rend le
|
||||
même résultat ; une réutilisation avec d'autres données est un conflit. Chaque
|
||||
modification exige la révision courante. La transaction verrouille le parent
|
||||
avant de répondre, empêchant la course avec une clôture ou un masquage.
|
||||
Les listes sont paginées (10 entrées), épingles puis date/id décroissants ; les
|
||||
réponses sont ordonnées par date/id croissants. L'intendance montre le bulletin
|
||||
visible le plus récent. Les textes et le nombre de résultats sont bornés.
|
||||
|
||||
## Vérification attendue
|
||||
|
||||
Même scénario sur fichier et vraie MariaDB : publication, réponses, redémarrage,
|
||||
idempotence, conflits, droits, masquage, fermeture et isolation de deux serveurs.
|
||||
Aller-retour Java → PHP → Java sur les mêmes tables. Essais client Vulkan FR/EN,
|
||||
GUI 2/3, mode compact, publication/refus/brouillon et fermeture avant réponse.
|
||||
Tests HTTP du site : identité, CSRF (middleware), validation, permissions,
|
||||
échappement HTML, erreurs, pagination. `./gradlew check build` et `assemblePack`
|
||||
sont consignés avec leurs éventuels échecs hérités.
|
||||
|
||||
## Mise en service
|
||||
|
||||
### Serveur autonome (fichier, défaut)
|
||||
|
||||
Installer le JAR ou le pack beta.154. La première ouverture d’un panneau crée
|
||||
`config/sanctuary-community.json` et son `serverId`. La première publication
|
||||
crée uniquement le fichier communautaire du monde. Aucun service externe requis.
|
||||
Les joueurs utilisent Échap → Gazette/Tableau → Publier, puis Répondre dans
|
||||
une discussion. Le mode fichier n’est pas partagé avec le site Web.
|
||||
|
||||
Conserver le `serverId` et sauvegarder le fichier communautaire avec son monde.
|
||||
Ne pas éditer ce fichier pendant que le serveur tourne. La limite de la v1 est
|
||||
64 Mio par fichier communautaire ; une écriture qui la dépasse est refusée.
|
||||
|
||||
### Même MariaDB pour Minecraft et Laravel
|
||||
|
||||
1. Sauvegarder la base existante. Déployer le code Web puis appliquer sa migration
|
||||
`php artisan migrate --force`. Elle ajoute quatre tables sans modifier les
|
||||
données de jeu. Un schéma déjà installé par `schema-v1.sql` est reconnu.
|
||||
À l’inverse, ne pas rejouer le SQL brut sur des tables déjà créées par Laravel.
|
||||
2. Dans le `.env` du site, garder ses paramètres MariaDB et définir
|
||||
`SANCTUARY_COMMUNITY_SERVER_ID` avec le même UUID que le mod. Recharger
|
||||
la configuration Laravel (`php artisan config:cache` en production).
|
||||
3. Arrêter Minecraft et éditer sa configuration :
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "database",
|
||||
"serverId": "11111111-1111-4111-8111-111111111111",
|
||||
"jdbcUrl": "jdbc:mariadb://db.example.net:3306/sanctuary?sslMode=verify-full",
|
||||
"user": "sanctuary_game",
|
||||
"passwordEnv": "SANCTUARY_COMMUNITY_DB_PASSWORD"
|
||||
}
|
||||
```
|
||||
|
||||
L’UUID ci-dessus est un exemple : reprendre celui de votre installation.
|
||||
Définir `SANCTUARY_COMMUNITY_DB_PASSWORD` dans l’environnement du service Minecraft,
|
||||
puis le redémarrer. Utiliser un certificat TLS valide pour l’hôte de la base.
|
||||
Le compte de jeu a besoin de SELECT sur `sanctuary_community_schema`, SELECT,
|
||||
INSERT et UPDATE sur `sanctuary_community_entries`, SELECT et INSERT sur
|
||||
`sanctuary_community_identities`, SELECT et DELETE sur `sanctuary_community_links`.
|
||||
Il n’a besoin ni des tables de comptes Web, ni de droits de création de tables.
|
||||
Le compte Laravel conserve ses permissions habituelles et celles des migrations.
|
||||
|
||||
4. Sur le site : se connecter avec Discord, ouvrir Gazette, demander un code de
|
||||
liaison. En jeu : Gazette → Lier mon compte → saisir le code. La liaison
|
||||
exige `online-mode=true`. Recharger le site après confirmation ; les boutons
|
||||
Publier et Répondre deviennent accessibles.
|
||||
5. Modération : opérateur Minecraft ou staff Web autorisé à `users.moderate`,
|
||||
avec compte lié. Les fonctions de lecture seule du staff ne donnent aucun
|
||||
droit de masquage, épinglage ou publication officielle.
|
||||
|
||||
L’accueil du site conserve sa version de téléchargement beta.151 tant que le
|
||||
pack public n’est pas mis à jour. Ce ticket ne publie pas le site, le canal
|
||||
packwiz ou une installation Prism.
|
||||
|
||||
### Synchronisation et limites de la v1
|
||||
|
||||
Le menu pause recharge ses aperçus toutes les 5 secondes. Les pages de lecture
|
||||
ont un bouton Actualiser. Le site charge les publications à chaque navigation.
|
||||
Les brouillons Minecraft restent en mémoire jusqu’à déconnexion ; le site garde
|
||||
ses brouillons dans le stockage local du navigateur, par compte et serveur.
|
||||
Aucune notification poussée ni import automatique d’archives ou de fichier vers
|
||||
MariaDB. Les anciennes publications d’exemple du site étaient du texte statique,
|
||||
pas des données à migrer. Les pièces jointes et la réattribution d’un compte lié
|
||||
restent hors périmètre.
|
||||
|
||||
### Présentation des discussions et visages
|
||||
|
||||
Les réponses se lisent en conversation : visage Minecraft, pseudo et date,
|
||||
texte dans un bloc distinct, actions compactes derrière « ⋯ ». Le formulaire
|
||||
reste sous la discussion. Les listes et les aperçus du menu pause affichent
|
||||
également les visages. En jeu, les profils sont résolus par les widgets natifs
|
||||
Minecraft, avec leur texture de repli tant que le skin n’est pas disponible.
|
||||
Sur le site, le rendu par UUID utilise le prestataire mc-api.io déjà employé
|
||||
par la page de compte ; chargement différé, sans référent, initiale locale en
|
||||
cas d’erreur. Aucune image ni URL arbitraire n’entre dans le contrat des posts.
|
||||
Documentation du rendu : https://mc-api.io/docs (GET `/render/{uuid}?size=64`).
|
||||
|
||||
|
||||
## Évolution v2 (beta.157)
|
||||
|
||||
Le champ photo et sa migration explicite, avec conservation des anciens articles,
|
||||
sont décrits dans [Photos de Gazette](gazette-photos-beta157.md).
|
||||
Pour une nouvelle base partagée avec le site, utiliser les migrations Laravel.
|
||||
Pour une base autonome administrée par SQL, appliquer schema-v1.sql puis
|
||||
schema-v1-to-v2.sql avant de connecter le mod beta.157.
|
||||
@@ -0,0 +1,96 @@
|
||||
# beta.157 — photo obligatoire et galerie de captures
|
||||
|
||||
## Contrat de migration communautaire v2
|
||||
|
||||
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
|
||||
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
|
||||
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
|
||||
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
|
||||
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
|
||||
|
||||
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
|
||||
leur prochaine modification exige une photo. Avis, intendance et réponses restent
|
||||
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
|
||||
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
|
||||
référence à un chemin local, d'URL distante ou de média orphelin persistant.
|
||||
|
||||
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
|
||||
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
|
||||
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
|
||||
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
|
||||
de restaurer la copie v1 et perd les changements communautaires postérieurs.
|
||||
Ne pas restaurer pendant que le serveur tourne.
|
||||
|
||||
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
|
||||
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
|
||||
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
|
||||
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
|
||||
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
|
||||
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
|
||||
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
|
||||
par la préparation de cette livraison.
|
||||
|
||||
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
|
||||
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
|
||||
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
|
||||
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
|
||||
seul le détail d'un article les contient. Le plafond des réponses
|
||||
passe à 262 144 caractères pour une photo et une discussion complète ; les
|
||||
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
|
||||
anciens clients ne peuvent plus créer d'article sans photo.
|
||||
|
||||
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
|
||||
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
|
||||
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
|
||||
Les captures illisibles restent visibles mais désactivées. Limites sources :
|
||||
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
|
||||
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
|
||||
du dossier `screenshots` de son instance.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
|
||||
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
|
||||
désactivé sans photo, envoi fragmenté, photo visible et relecture après
|
||||
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
|
||||
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
|
||||
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
|
||||
copie de migration exacte, fragments hors ordre/incomplets/expirés.
|
||||
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
|
||||
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
|
||||
anciennes publications et conservation après modification.
|
||||
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
|
||||
modification et réponse Web relues en Java, article/photo du site décodés par
|
||||
Java. Migration Laravel et alternative SQL testées sur bases de développement.
|
||||
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
|
||||
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
|
||||
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
|
||||
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
|
||||
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
|
||||
indépendants, pagination, article et avis longs, réponse, options et reprise :
|
||||
réussie avec les articles munis d'une photo.
|
||||
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
|
||||
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
|
||||
client natif du menu pause), 2 min 56 s.
|
||||
|
||||
Les captures d'essai sont des mires générées dans une instance de développement,
|
||||
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
|
||||
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
|
||||
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
|
||||
|
||||
|
||||
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
|
||||
`9626e95`, sans push ni publication. Logs de développement :
|
||||
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
|
||||
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
|
||||
`build/photos157-final-build.log`.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
|
||||
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
|
||||
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
|
||||
Reçu complet : `build/photos157-validation.json`.
|
||||
|
||||
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
|
||||
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# RENDER-149 — Distances PBR et SSR indépendantes
|
||||
|
||||
Branche `codex/separate-reflection-distances-beta149`, socle beta.148 `6f87144`.
|
||||
|
||||
- Deux réglages : Distance PBR et Distance SSR, chacun à 64 blocs par défaut.
|
||||
Choix 16, 32, 64, 128, 256 blocs, enregistrés séparément.
|
||||
- Le PBR conserve son propre fondu et sa propre limite de portée. Le SSR
|
||||
utilise sa distance pour les surfaces réfléchissantes et sa recherche de
|
||||
paysage. Le maillage et la sélection des surfaces couvrent le maximum des
|
||||
deux portées actives, afin que réduire l’une ne coupe pas l’autre.
|
||||
- Ancien défaut commun 32 migré une fois vers PBR 64 ; nouveau réglage SSR 64.
|
||||
Les anciennes valeurs PBR différentes de 32 restent conservées. Après la
|
||||
migration, un choix explicite 32 est conservé comme les autres valeurs.
|
||||
- Option nommée exactement « SSR » en français et en anglais, sans la mention
|
||||
« expérimental ». Les autres options expérimentales du jeu sont inchangées.
|
||||
- Intensités PBR 50 % et SSR 20 %, rendu solaire diffus de beta.148 conservés.
|
||||
SSR conserve sa dépendance au PBR et les limites du paysage visible/chargé
|
||||
et du budget de maillage.
|
||||
|
||||
- Les feuilles (et fibres de laine, même profil entièrement mat) ne reçoivent
|
||||
plus de large éclat spéculaire blanc. Leur relief normal reste présent.
|
||||
La pierre et le bois retrouvent exactement leur poids spéculaire précédent,
|
||||
comme les surfaces polies, les métaux, l’eau et le verre. La suppression
|
||||
concerne les profils non métalliques de rugosité 0,98, avec transition 0,90–0,98.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Contrôles de persistance indépendante, valeurs par défaut et libellés FR/EN.
|
||||
Scènes graphiques avec PBR variable/SSR 256 puis PBR 256/SSR variable.
|
||||
- Suite PBR/préférences Vulkan réussie : deux défauts 64 et persistance
|
||||
indépendante sur 16/32/64/128/256 ; libellés FR/EN et nom SSR vérifiés.
|
||||
Journal : `build/beta149-pbr-vulkan.log`.
|
||||
- Suites SSR Vulkan et OpenGL réussies avec les portées dissociées, F5,
|
||||
intersections, eau/verre/métal, soleil/lune, OFF/zéro et rechargement.
|
||||
Journaux : `build/beta149-ssr-vulkan.log`, `build/beta149-ssr-opengl.log`.
|
||||
Ces passes précèdent l’ajustement final du profil entièrement mat.
|
||||
- Suite PBR Vulkan finale avec feuillage réussie : 170 172 pixels de feuilles
|
||||
contrôlés, saturation relative 1,00007, relief visible sur 63 236 pixels.
|
||||
Journal : `build/beta149-foliage-vulkan.log` ; captures correspondantes
|
||||
dans `build/evidence/beta149-foliage-vulkan/`.
|
||||
- Bois et briques après recentrage sur le feuillage : aucun pixel modifié
|
||||
par rapport à la capture précédant l’ajustement, sur les zones de contrôle
|
||||
de 26 304 et 40 896 pixels respectivement.
|
||||
- `./gradlew check build` exécuté dans l’instantané isolé : 252 tests serveur,
|
||||
229 réussis et 23 échecs, mêmes identifiants qu’en beta.148. Aucun nouvel échec.
|
||||
Journal : `build/beta149-check-build-isolated.log` ; comparaison :
|
||||
`build/beta149-server-failures.json`. L’ajustement final du feuillage concerne
|
||||
seulement le shader et son test client. Le contrôle global reste en échec.
|
||||
|
||||
- Suite PBR OpenGL finale réussie également : 170 199 pixels de feuilles,
|
||||
saturation relative 1,00001, relief visible sur 63 263 pixels.
|
||||
Journal : `build/beta149-foliage-opengl.log` ; captures correspondantes
|
||||
dans `build/evidence/beta149-foliage-opengl/`.
|
||||
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
|
||||
sans répéter les contrôles purs/serveur exécutés dans l’instantané.
|
||||
Journal : `build/beta149-build-pack.log`. Cette exclusion ne transforme
|
||||
pas le contrôle complet en réussite.
|
||||
|
||||
## Artefact
|
||||
|
||||
[Sanctuary-beta.149.mrpack](../build/Sanctuary-beta.149.mrpack), copié aussi dans
|
||||
`sanctuary-beta/build/` avec les autres versions. Archive vérifiée, un seul JAR
|
||||
Sanctuary beta.149 identique au JAR construit, shaders identiques aux sources,
|
||||
libellés SSR et deux portées FR/EN vérifiés. Minecraft 26.3 / Loader 0.19.5.
|
||||
Taille : 10 520 465 octets. SHA-256 :
|
||||
`ef5519d9a0e2006b865b62371a6f8712db02ab138aa9698339d6fdb02fb4befa`.
|
||||
Reçu : `build/beta149-artifacts.json`. Aucune installation Prism ni publication.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# RENDER-150 — Terre mate et reflets solaires colorés
|
||||
|
||||
Branche `codex/matte-dirt-beta150`, socle beta.149 `1c23d0c`.
|
||||
|
||||
La terre sèche utilise un profil entièrement mat (rugosité 0,98, métal 0),
|
||||
comme le feuillage corrigé en beta.149. Cela enlève l’éclat blanc/gris diffus
|
||||
sans supprimer les normales ni le contraste de relief. Le SSR de ces matières
|
||||
est également atténué par leur rugosité, sans changer son réglage global.
|
||||
|
||||
Blocs concernés : dirt, coarse_dirt, rooted_dirt, grass_block, dirt_path,
|
||||
farmland, podzol et mycelium. Les faces terreuses suivent ainsi le même profil.
|
||||
Les autres profils de matière sont inchangés.
|
||||
|
||||
Le disque du soleil et son reflet utilisent la même couleur native
|
||||
`SkyRenderState.sunriseAndSunsetColor`, issue de l’attribut environnemental
|
||||
Minecraft `SUNRISE_SUNSET_COLOR`. Son alpha pilote la transition depuis le
|
||||
blanc : aucune palette horaire ou altitude artificielle. Le soleil reste
|
||||
neutre tant que le dégradé natif est absent et se colore avec le ciel.
|
||||
La teinte solaire est convertie en lumière linéaire pour le PBR. Le mixin
|
||||
modifie seulement la couleur du disque solaire, préserve l’alpha de pluie,
|
||||
la texture native et la lune, et respecte la désactivation globale de Vanilla Light.
|
||||
La fusion diffuse de l’eau et sa normale animée d’origine sont conservées.
|
||||
Les accents suivent les pentes des vaguelettes qui réfléchissent le soleil
|
||||
vers la caméra. Leur pulsation vient des normales animées de la texture,
|
||||
sans oscillateur indépendant ni inversion artificielle du relief.
|
||||
Une faible variation stable par texel évite des crêtes toutes identiques ;
|
||||
elle module seulement l’intensité, sans produire des points isolés aléatoires.
|
||||
Les détails s’atténuent lorsqu’ils deviennent plus petits qu’un pixel écran.
|
||||
Les accents restent limités au reflet solaire et conservent sa couleur.
|
||||
Les lampes et la lune gardent leurs
|
||||
couleurs propres.
|
||||
PBR 50 %, SSR 20 % et deux portées indépendantes de 64 blocs par défaut conservés.
|
||||
Minecraft 26.3, Java 25 et dépendances inchangés ; aucune sauvegarde modifiée.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Tests des profils de terre et scènes GPU comparant PBR OFF/ON sur terre et
|
||||
feuillage : conservation de la couleur native et présence du relief.
|
||||
- Suites PBR finales Vulkan/OpenGL réussies, dont profils des huit types de
|
||||
sol, proximité, intensités, préférences et feuillage.
|
||||
Journaux : `build/beta150-pbr-vulkan-final.log` et
|
||||
`build/beta150-pbr-opengl-final.log`.
|
||||
- Terre : 180 469 pixels contrôlés, saturation relative 0,99877 sur les deux
|
||||
moteurs ; relief visible sur 145 415 pixels. Captures conservées dans
|
||||
`build/evidence/beta150-pbr-vulkan-final/` et
|
||||
`build/evidence/beta150-pbr-opengl-final/`.
|
||||
|
||||
Les tests comparent la contribution PBR au rendu natif et capturent aussi
|
||||
le disque du soleil, avant puis pendant la transition native du ciel.
|
||||
Le contrôle serveur `./gradlew check build`, exécuté dans une copie isolée
|
||||
`build/server-validation` avant les derniers ajouts exclusivement client,
|
||||
compte 229 réussites et 23 échecs sur 252 tests. Les 23 identifiants d’échec
|
||||
sont identiques à beta.149 ; aucun nouvel identifiant. Le contrôle global
|
||||
n’est donc pas vert. Journaux : `build/beta150-check-build-isolated.log` et
|
||||
`build/beta150-server-failures.json`.
|
||||
|
||||
Les scènes solaires comparent le rendu PBR au natif, avant et pendant le
|
||||
coucher de soleil. À 2 000 ticks, la teinte native est blanche ; à 11 500 ticks
|
||||
sa transition commence légèrement (alpha 0,051) ; à 12 400 ticks l’alpha
|
||||
natif atteint 0,831 et le disque devient chaud (centre RGB 255/255/135).
|
||||
Les deux moteurs ont validé cette synchronisation native.
|
||||
|
||||
Un lancement sans sélection PBR a également rencontré l’assertion client
|
||||
Blocodex « The full snapshot must become visible together », avant les scènes
|
||||
GPU (`build/beta150-water-motion-vulkan.log`). Il ne constitue pas une
|
||||
validation de la suite client générale.
|
||||
|
||||
La suite finale Vulkan des éclats liés aux vaguelettes passe
|
||||
(`build/beta150-wave-glints-vulkan.log`) : animation native, masquage F5,
|
||||
distances indépendantes, reflets solaires et lunaires, matériaux transparents,
|
||||
rechargement des ressources et redimensionnement. Dans la scène solaire,
|
||||
150 563 pixels sont éclaircis, sans pixel blanc saturé.
|
||||
|
||||
La suite OpenGL finale passe également
|
||||
(`build/beta150-wave-glints-opengl.log`) : 148 180 pixels éclaircis, aucun
|
||||
pixel blanc saturé. Les captures finales sont conservées dans
|
||||
`build/evidence/beta150-wave-glints-vulkan/` et
|
||||
`build/evidence/beta150-wave-glints-opengl/`.
|
||||
Le ressenti des vaguelettes en mouvement reste à confirmer par le créateur
|
||||
sur sa configuration ; les tests ne constituent pas une validation esthétique.
|
||||
|
||||
## Pack local
|
||||
|
||||
Construction et assemblage réussis avec
|
||||
`./gradlew build assemblePack -x :sanctuary:check`, après le contrôle complet
|
||||
ci-dessus. L’exclusion ne transforme pas ses échecs connus en réussite.
|
||||
Export packwiz puis vérification ZIP, version Fabric, Minecraft 26.3,
|
||||
Loader 0.19.5, JAR unique identique au construit, shader final et mixin solaire.
|
||||
|
||||
- Pack : `/Users/koka/Documents/sanctuary/sanctuary-beta/build/Sanctuary-beta.150.mrpack`
|
||||
- Taille : 10522942 octets.
|
||||
- SHA-256 pack : `65a4a44948dcdcf0325b5e24c076a03f8832184f25f5c1ff16f69bd3ee641b93`.
|
||||
- SHA-256 JAR : `4962e6f8cafd6ef3e81e088bf9b0092a264441632cfa095d4fe7394b855f093d`.
|
||||
- Reçu : `build/beta150-artifacts.json`, également copié à côté du pack.
|
||||
|
||||
Aucun canal publié, aucune instance Prism ni sauvegarde personnelle modifiée.
|
||||
@@ -0,0 +1,93 @@
|
||||
# FIX-151 — Nether, PBR opaque et SSR
|
||||
|
||||
Branche `codex/nether-wood-beta151`, socle beta.150 `448e2fe`.
|
||||
|
||||
## Déconnexion Nether
|
||||
|
||||
Le rapport Windows beta.150 transmis le 18 septembre 2026 indique
|
||||
`IndexOutOfBoundsException: Index 46 out of bounds for length 46` lors de
|
||||
`ClientPacketListener.handleContainerContent`, paquet `container_set_content`.
|
||||
Le passage au Nether dans le test client local reproduit exactement cette trace
|
||||
(journal `build/beta151-client-first.log`). Ce défaut n’est pas un crash GPU.
|
||||
|
||||
Minecraft recrée le joueur client et son menu lors de `handleRespawn`, utilisé
|
||||
aussi pour les changements de dimension. La progression n’était réattachée
|
||||
qu’en fin de tick ; un paquet d’inventaire dans le même lot arrivait avant la
|
||||
création des 18 cases supplémentaires et des 3 accessoires. Le correctif lie la
|
||||
progression déjà reçue au nouveau joueur dès la fin du traitement du paquet de
|
||||
respawn, avant le paquet suivant. Aucun paquet ni objet n’est ignoré et aucun
|
||||
format de sauvegarde ne change. Sans progression Sanctuary active, le chemin
|
||||
vanilla reste inchangé.
|
||||
|
||||
## Rendu
|
||||
|
||||
La beta.146 avait porté le gain solaire des surfaces opaques de 3 à 20
|
||||
(4 × 5), soit environ 6,7 fois plus. Le renforcement prévu pour les reflets de
|
||||
l’eau avait ainsi créé un voile sur les matières sombres, dont l’obsidienne.
|
||||
Les surfaces opaques retrouvent le gain 3 et le budget spéculaire total 0,5,
|
||||
partagé entre soleil et lampes. La teinte native du coucher de soleil, les
|
||||
normales et le relief sont conservés. Eau et verre conservent leur traitement.
|
||||
Le bois utilise le profil mat 0,98 : planches, bûches et variantes de construction,
|
||||
bambou, tiges et hyphes du Nether. Ce classement ne transforme pas le nylium en bois.
|
||||
|
||||
SSR est activé par défaut à 20 %, avec portée 64 blocs. Un choix OFF déjà
|
||||
sauvegardé reste OFF. PBR reste à 50 % par défaut.
|
||||
|
||||
## Cible graphique
|
||||
|
||||
Décision du créateur pendant ce ticket : **Vulkan uniquement** pour les nouvelles
|
||||
validations. Le passage OpenGL de ce ticket s’était terminé avant cette demande ;
|
||||
il reste une trace historique, pas une cible à conserver ni à relancer.
|
||||
Cette décision est reportée dans `AGENTS.md` et `docs/testing.md`.
|
||||
|
||||
## Vérification
|
||||
|
||||
Les assertions GPU Vulkan et les quatre transferts successifs
|
||||
Overworld → Nether → Overworld → Nether → Overworld passent sur un nouveau monde
|
||||
plat de développement, graine 122. Après chaque transfert : menu de 67 cases,
|
||||
pile de 7 diamants en inventaire natif, 3 émeraudes dans la première case
|
||||
supplémentaire et 5 lingots d’or dans la dernière, toujours présents côté client.
|
||||
|
||||
Les scènes PBR conservent 99,878 % de la saturation relative du chêne et 99,968 %
|
||||
de celle de l’épicéa écorcé sur 185 776 pixels chacune. Le relief affecte
|
||||
respectivement 101 382 et 51 671 pixels. De face, l’obsidienne ne gagne en moyenne
|
||||
que 5,114 niveaux RGB sur 255 (102 480 pixels), avec relief visible sur 95 225
|
||||
pixels. Captures inspectées : bois et obsidienne PBR OFF/ON.
|
||||
|
||||
Les contrôles couvrent aussi les réglages SSR absents (ON/20 %), la persistance
|
||||
d’un choix OFF, les profils bois, les petits déplacements de caméra, les
|
||||
intensités, le rechargement des ressources et le redimensionnement.
|
||||
Le premier passage Vulkan corrigé valide toutes les assertions, mais se bloque
|
||||
ensuite à la fermeture dans les barrières de synchronisation du programme de
|
||||
test Fabric ; son processus est arrêté après capture des piles de threads.
|
||||
Premier journal : `build/beta151-client-vulkan-final.log` ; diagnostic :
|
||||
`build/beta151-client-shutdown-threads.txt`. Le second passage Vulkan réussit entièrement, fermeture comprise, en **3 min 9 s**
|
||||
(`build/beta151-client-vulkan-clean.log`). La suite OpenGL terminée avant l’abandon de
|
||||
cette cible a réussi en 2 min 42 s ; aucun nouveau test OpenGL n’est prévu.
|
||||
|
||||
Le contrôle complet `./gradlew check build` dans `build/server-validation`
|
||||
(3 068 fichiers de production identiques aux sources courantes) termine en
|
||||
11 min 13 s : **229 réussites, 23 échecs sur 252**. Les 23 identifiants sont
|
||||
strictement identiques à beta.150 : aucun nouveau, aucun résolu.
|
||||
Le contrôle global reste rouge. Journaux et liste :
|
||||
`build/beta151-check-build.log`, `build/beta151-server-failures.json`.
|
||||
Ces échecs restent ouverts et ne constituent pas la validation du correctif
|
||||
réseau ; celui-ci repose sur la reproduction puis le test client des transferts.
|
||||
|
||||
## Pack local
|
||||
|
||||
`./gradlew build assemblePack -x :sanctuary:check` réussit en 11 s,
|
||||
indépendamment du contrôle complet en échec ci-dessus. Export packwiz réussi ;
|
||||
ZIP, version Fabric, Minecraft 26.3, Loader 0.19.5, JAR Sanctuary unique,
|
||||
mixin de respawn et shader exacts, classes conformes aux sorties compilées et
|
||||
absence des tests dans le JAR vérifiés.
|
||||
|
||||
- Pack : `/Users/koka/Documents/sanctuary/sanctuary-beta/build/Sanctuary-beta.151.mrpack`.
|
||||
- SHA-256 pack : `1d87804d75edda0a95c318ea3feaf47ecadeeb29d86ca55483a42741d7a5fcab`.
|
||||
- SHA-256 JAR : `6676c67f8be4e650a9b6a8213c8b0fdf3cf7e62b030c3d1a6d4805d438a8e543`.
|
||||
- Reçu : `build/beta151-artifacts.json`.
|
||||
|
||||
Les essais sont effectués sur Apple M1 ; la validation sur le PC Windows du
|
||||
créateur reste à faire.
|
||||
|
||||
Aucune sauvegarde personnelle ouverte, aucun canal publié ni instance installée.
|
||||
@@ -0,0 +1,114 @@
|
||||
# PAUSE-155 — une entrée Sanctuary, trois espaces
|
||||
|
||||
Branche `codex/pause-redesign-beta155`, livraison locale `beta.155`.
|
||||
Suite de [COMMUNITY-154](community-beta154.md).
|
||||
|
||||
## Résultat attendu
|
||||
|
||||
Remplacer l'ancienne grille du menu pause par trois espaces sans fond ou
|
||||
séparateurs imposés à chaque espace :
|
||||
|
||||
- En-tête : date locale à gauche, dernier message d'intendance au centre,
|
||||
heure locale à droite. Le message ouvre directement sa publication.
|
||||
- Corps : navigation permanente Découverte, Monde, Progression, Habitant,
|
||||
Faction et Batailles à gauche, tous espacés de 4 pixels comme les boutons
|
||||
d’options. Carte personnelle au centre. Gazette au-dessus du tableau d'affichage à droite.
|
||||
- Bas de la colonne gauche : Options, Options du monde, puis Sauvegarder et
|
||||
quitter, empilés dans cet ordre et alignés avec les six boutons Sanctuary.
|
||||
Reprendre la partie se trouve seul tout en bas de la colonne centrale,
|
||||
sous la carte. Les actions natives Minecraft sont réutilisées, y compris
|
||||
Déconnexion en multijoueur. À grande échelle d'interface, les six boutons
|
||||
de navigation deviennent plus compacts pour préserver cette séparation.
|
||||
|
||||
La Gazette présente des boutons-titres avec la tête de l’auteur dans un carré
|
||||
de 20 pixels à gauche, sur la même ligne ; le nom reste disponible au survol. Les demandes prennent la forme de
|
||||
cartes encadrées avec aperçu, auteur, catégorie et état. Chaque zone possède
|
||||
son propre défilement à la molette et sa barre de défilement. Le bouton +
|
||||
ouvre la rédaction, l'en-tête ouvre la liste complète. « Afficher la suite »
|
||||
ajoute la page suivante sans quitter le menu ni remettre le défilement à zéro.
|
||||
La lecture des publications longues et des conversations utilise le
|
||||
conteneur défilant natif existant.
|
||||
|
||||
## Périmètre technique
|
||||
|
||||
La carte utilise exclusivement les relevés personnels de l'atlas existant,
|
||||
sans chargement ou génération de chunks. Elle respecte le déblocage de
|
||||
l'atlas et les restrictions de dimension. Un clic ouvre la carte détaillée.
|
||||
Le serveur solo transmet les relevés existants pendant la pause, par lots bornés,
|
||||
sans observer de nouveaux blocs ni avancer la simulation. La limite de fréquence
|
||||
accepte aussi le temps réel lorsque les ticks du monde sont arrêtés.
|
||||
La texture et le flux réseau sont libérés en quittant le menu ; les réponses
|
||||
tardives ne sont acceptées que par l'écran courant et son jeton.
|
||||
|
||||
Les publications utilisent les mêmes opérations paginées du contrat v1,
|
||||
en fichier comme en MariaDB. Aucune migration, table ou modification du site.
|
||||
L'actualisation des premières pages intervient lorsque leur défilement est en
|
||||
haut ; une liste étendue reste stable jusqu'à sa réouverture.
|
||||
Les textes d'interface sont disponibles en français et anglais.
|
||||
|
||||
La sélection d'un screenshot pour la Gazette reste l'étape suivante.
|
||||
Les visages des demandes du tableau restent différés ; ceux de la Gazette
|
||||
et des conversations sont présents. Les futures entrées
|
||||
économiques ne sont pas des boutons factices.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Environnement : macOS Apple M1, Java Temurin 25, Minecraft 26.3, Fabric Loader
|
||||
0.19.5, Fabric API 0.160.5+26.3, Vulkan/MoltenVK 1.4.2. Base de développement
|
||||
MariaDB 12.3.3, distincte des données personnelles et du site public.
|
||||
|
||||
- Essais natifs Vulkan réussis dans les deux modes : fichier et MariaDB.
|
||||
Français et anglais, GUI 2/3/4 à 1280 × 960 pixels (jusqu'à 320 × 240 unités
|
||||
d'interface), contrôles contenus dans l'écran.
|
||||
- Six vrais boutons Sanctuary ; espacement uniforme de 4 pixels en haut et
|
||||
en bas ; avatars d’articles alignés à gauche des titres.
|
||||
Défilements indépendants à la molette, ajout de la treizième publication par
|
||||
pagination sans retour en haut, lecture jusqu'au bas des longs articles et
|
||||
demandes, réponse publiée et relue.
|
||||
- Options et Options du monde natives, reprise du jeu, ouverture de la carte
|
||||
détaillée puis retour au menu. Carte personnelle reçue en plusieurs lots
|
||||
pendant la pause ; l'horloge de simulation du monde reste immobile.
|
||||
- Logs : `build/pause155-client-file.log`, `build/pause155-client-database.log`.
|
||||
Captures : `build/pause155-{file,database}-screenshots/`, aperçu
|
||||
`build/pause155-preview.png`. Les articles et le terrain de ces captures
|
||||
proviennent exclusivement d'un monde de test plat et de données fictives.
|
||||
|
||||
`./gradlew check build` a terminé ses 252 GameTests : 229 réussites et les
|
||||
23 échecs déjà recensés dans beta.154 (mêmes identifiants, aucun ajout ni
|
||||
retrait). La commande échoue donc ; ce n'est pas un contrôle global vert.
|
||||
Log : `build/pause155-check-build.log`, comparaison :
|
||||
`build/pause155-server-failures.json` / `build/community-server-failures.json`.
|
||||
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit :
|
||||
compilation, contrôles hors GameTests et assemblage. Les derniers ajustements
|
||||
visuels de disposition sont ensuite vérifiés par les scénarios client natifs
|
||||
et un nouvel assemblage des artefacts, sans réexécuter les 252 GameTests.
|
||||
Log : `build/pause155-build-pack.log`.
|
||||
Aucun déploiement personnel ni publication du canal packwiz.
|
||||
|
||||
Commande des essais natifs :
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
|
||||
-PsanctuaryQuickTests=true -PsanctuaryPause155ClientTests=true \
|
||||
-PsanctuaryClientGraphicsBackend=vulkan
|
||||
```
|
||||
|
||||
Le scénario MariaDB utilise les variables de développement documentées dans
|
||||
[COMMUNITY-154](community-beta154.md). Le schéma reste le contrat v1.
|
||||
La sélection de screenshots et la validation Windows restent ouvertes.
|
||||
|
||||
|
||||
## Artefacts locaux vérifiés
|
||||
|
||||
Assemblage final réussi avec `./gradlew build assemblePack -x :sanctuary:check`,
|
||||
après réussite des essais natifs de la disposition finale dans les deux modes.
|
||||
Export packwiz réussi. ZIP, version Fabric/Minecraft, JAR unique dans le MRpack,
|
||||
pilote MariaDB embarqué et absence des classes de test vérifiés. Les 29 fichiers
|
||||
shader sont identiques octet pour octet à beta.151. Les anciens JAR et le pack
|
||||
beta.154 sont conservés, leurs empreintes vérifiées.
|
||||
|
||||
Reçu : `build/pause155-artifacts.json`. Logs : `build/pause155-final-assemble.log`
|
||||
et `build/pause155-export.log`.
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.155.jar` — SHA-256 `e8ba34ddd2726eb48fcea2b56a6f5a2e0262354553378c5bc25ca6f5061d32dc`.
|
||||
- `build/Sanctuary-beta.155.mrpack` — SHA-256 `11be47c07edbb8180b4d6911a1eff7656ba2ce4168d35b96096519a2491e899f`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# PAUSE-156 — reprendre la partie sous la colonne droite
|
||||
|
||||
Branche `codex/pause-resume-right-beta156`, livraison locale `beta.156`.
|
||||
Suite de [PAUSE-155](pause-redesign-beta155.md).
|
||||
|
||||
Le bouton natif Reprendre la partie se trouve en bas à droite, sous la colonne
|
||||
Gazette / tableau, avec le même alignement et la même largeur. Il conserve son
|
||||
action native et les libellés français / anglais de Minecraft.
|
||||
|
||||
La modification concerne uniquement sa géométrie. Les options restent en bas
|
||||
à gauche ; les publications, le stockage, la carte et les shaders conservent
|
||||
leur fonctionnement de beta.155. Aucune migration ni installation personnelle.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Le scénario client Vulkan existant passe en français et anglais, GUI 2/3/4 à
|
||||
1280 × 960 : alignement du bouton avec la colonne droite, largeur identique,
|
||||
absence de chevauchement avec le tableau, reprise effective du jeu. Les
|
||||
interactions communautaires du scénario passent également en mode fichier.
|
||||
Log : `build/pause156-client.log`, aperçu `build/pause156-preview.png`.
|
||||
|
||||
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit.
|
||||
Export packwiz, intégrité ZIP, versions du mod / pack et contenu du JAR
|
||||
embarqué vérifiés. Les shaders sont inchangés et les artefacts beta.155
|
||||
conservés avec leurs empreintes. Logs : `build/pause156-build-pack.log`,
|
||||
`build/pause156-export.log`, reçu `build/pause156-artifacts.json`.
|
||||
La suite serveur complète de beta.155 compte 229 réussites / 252, avec les
|
||||
23 échecs historiques. Elle n’est pas relancée pour ce déplacement de bouton.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.156.jar` — SHA-256 `c325a757cff6063fe3702e755a57a3abc13ad86e071762354a3c449504d51c6f`.
|
||||
- `build/Sanctuary-beta.156.mrpack` — SHA-256 `0537273dbfecb071d481f3f22982fbbb9a8e8a75581e38128ded87926ee699d0`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# RENDER-147 — PBR proche, lune et portée 256 blocs
|
||||
|
||||
Branche `codex/pbr-close-range-beta147`, socle beta.146 `cf9a670`.
|
||||
Minecraft 26.3, Java 25 ; dépendances et formats de sauvegarde inchangés.
|
||||
|
||||
## Résultat
|
||||
|
||||
- Le prépassage des récepteurs PBR comparait encore les profondeurs natives
|
||||
avec un epsilon fixe de 0,0000001. En profondeur inversée, cette marge
|
||||
devenait trop stricte au voisinage de la caméra : de nombreux fragments
|
||||
alternaient entre le rendu natif et le PBR lors de mouvements minuscules.
|
||||
- La comparaison utilise maintenant une distance reconstruite avec la marge
|
||||
bornée de 5 mm déjà employée par les passes couleur. La sélection de la
|
||||
surface PBR la plus proche et son test de profondeur EQUAL restent stricts.
|
||||
Aucun décalage du maillage ni fondu supprimant le PBR près de la caméra.
|
||||
- La lune ne reçoit plus le renforcement spéculaire du soleil (×20 pour
|
||||
l’eau, ×4 pour les autres matières). Son coefficient lunaire et ses phases
|
||||
restent appliqués ; le grand reflet solaire est conservé.
|
||||
- Distance commune PBR/SSR : 16, 32 (défaut), 64, 128 ou 256 blocs.
|
||||
À 256, le rayon SSR peut aussi rechercher jusqu’à 256 blocs. Nombre de pas
|
||||
et budgets de maillage inchangés ; les limites du terrain visible/chargé,
|
||||
des reflets hors écran et du budget de maillage subsistent.
|
||||
- PBR 80 %, SSR 20 %, mélange des eaux et préférences de beta.146 conservés.
|
||||
Libellés FR/EN mis à jour.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Mondes de test jetables, macOS Apple M1. Aucune sauvegarde personnelle utilisée.
|
||||
|
||||
- Reproduction avant correction sous Vulkan : à 12 cm du sol de briques,
|
||||
59 442 / 262 272 pixels changent de plus de 12 niveaux RVB cumulés sous une
|
||||
rotation de 0,00002 degré. Le test échoue. Journal :
|
||||
`build/beta147-close-before-vulkan.log`.
|
||||
- Même scène après correction : 1 / 262 272 pixels à 12 cm ; également testé
|
||||
à 25 cm, 50 cm et 1 m. Suite PBR Vulkan réussie :
|
||||
`build/beta147-close-after-vulkan.log`.
|
||||
- Preuves avant/après dans `build/evidence/close-before-vulkan/` et
|
||||
`build/evidence/close-after-vulkan/`.
|
||||
|
||||
- Suite PBR et préférences OpenGL finale réussie : mêmes scènes proches,
|
||||
persistance des distances dont 256 blocs, FR/EN, OFF/zéro, intensités,
|
||||
rechargement, redimensionnement et effets combinés.
|
||||
Journal : `build/beta147-close-final-opengl.log` ; preuves :
|
||||
`build/evidence/beta147-close-final-opengl/`.
|
||||
|
||||
- Suite SSR Vulkan finale réussie : F5, intersections, portées dont 256,
|
||||
eau/verre/métal, OFF/zéro, rechargement et redimensionnement.
|
||||
Journal : `build/beta147-ssr-final-vulkan.log` ; preuves :
|
||||
`build/evidence/beta147-ssr-final-vulkan/`.
|
||||
- À PBR 80 % / SSR 20 %, le soleil conserve 22 697 pixels quasi blancs sur
|
||||
la scène aquatique 854 × 480. La lune seule en compte zéro, tout en modifiant
|
||||
visiblement 40 808 pixels par rapport au PBR OFF. Capture du bloom lunaire
|
||||
inspectée : reflet doux, sans plateau blanc.
|
||||
|
||||
- Suite SSR OpenGL finale réussie également : zéro pixel quasi blanc pour
|
||||
la lune, 22 111 pour le soleil et 40 749 pixels de reflet lunaire visibles.
|
||||
Intersections : 91 / 518 400 pixels changés sous rotation minuscule.
|
||||
Journal : `build/beta147-ssr-final-opengl.log` ; captures correspondantes
|
||||
dans `build/evidence/beta147-ssr-final-opengl/`.
|
||||
- `./gradlew check build` dans un instantané isolé : 252 tests serveur,
|
||||
229 réussis, 23 échecs, avec exactement les mêmes identifiants que beta.146.
|
||||
Journal : `build/beta147-check-build-isolated.log` ; comparaison :
|
||||
`build/beta147-server-failures.json`. La vérification globale reste en échec.
|
||||
Les derniers ajouts lune/256 concernent exclusivement le rendu client,
|
||||
ses réglages et ses tests ; ils sont validés par les suites finales ci-dessus.
|
||||
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
|
||||
après les contrôles purs et serveur exécutés dans l’instantané, sans les
|
||||
répéter. Journal : `build/beta147-build-pack.log`. Cette exclusion ne
|
||||
transforme pas le contrôle complet en réussite.
|
||||
|
||||
## Artefact
|
||||
|
||||
[Sanctuary-beta.147.mrpack](../build/Sanctuary-beta.147.mrpack), également copié
|
||||
à côté de beta.146 dans `sanctuary-beta/build/`. Aucun ancien pack remplacé,
|
||||
aucune installation Prism ou publication effectuée.
|
||||
|
||||
Archive ZIP vérifiée, un seul JAR Sanctuary beta.147, identique au JAR construit,
|
||||
shaders identiques aux sources finales, options FR/EN 256 présentes,
|
||||
Minecraft 26.3 / Fabric Loader 0.19.5 conservés. Taille : 10 519 851 octets.
|
||||
SHA-256 : `b8044243dbe160d5171ea584052cf5732393e4d91352c29c33b727af80c0a5bc`.
|
||||
Reçu : `build/beta147-artifacts.json`.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# RENDER-148 — PBR 50 % et reflet solaire diffus
|
||||
|
||||
Branche `codex/pbr-soft-sun-beta148`, socle beta.147 `96a36d9`.
|
||||
Minecraft 26.3, Java 25, dépendances et sauvegardes inchangés.
|
||||
|
||||
## Résultat
|
||||
|
||||
- PBR à 50 % par défaut, SSR toujours à 20 % et portée commune jusqu’à 256.
|
||||
- Les configurations anciennes contenant exactement PBR 80 % passent à 50 %.
|
||||
Un marqueur de migration conserve ensuite tout choix explicite, y compris
|
||||
un nouveau choix 80 %. Les autres anciennes intensités restent conservées.
|
||||
- Le reflet solaire de l’eau avait un renforcement ×20 et une compression
|
||||
exponentielle qui effaçait les détails dans une large zone blanche.
|
||||
Le renforcement passe à ×6 et le lobe est plus diffus (rugosité minimale
|
||||
de 0,42 au lieu de 0,34 pour le lobe céleste de l’eau).
|
||||
- Pour le soleil sur l’eau, fusion par texel entre éclaircissement vers le
|
||||
blanc et lumière douce fondée sur la couleur native. La pondération emploie
|
||||
une compression rationnelle, sans atteindre brutalement un plateau blanc.
|
||||
La texture et ses animations restent natives ; aucun bruit ajouté.
|
||||
- La lune conserve son intensité atténuée, sans renforcement solaire.
|
||||
Les lampes et matières solides conservent leur fusion spéculaire précédente.
|
||||
Le bloom reste réglable et suit la lumière réellement ajoutée.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Scènes plates jetables, Apple M1, Vulkan et OpenGL. Le test solaire contrôle
|
||||
une lumière largement visible et limite les pixels quasi blancs à moins de
|
||||
0,5 % de l’image, au lieu d’imposer une grande tache blanche.
|
||||
|
||||
Premier contrôle Vulkan à PBR 50 % / SSR 20 % : 149 908 pixels éclaircis,
|
||||
zéro pixel quasi blanc sur 854 × 480. Captures vues du dessus et à soleil bas
|
||||
inspectées : la texture reste visible dans la traînée lumineuse.
|
||||
Preuves : `build/evidence/beta148-preview/`.
|
||||
Rendu de la traînée solaire approuvé par le créateur après inspection de la capture.
|
||||
|
||||
- Suites SSR complètes Vulkan et OpenGL réussies : F5, intersections, portées,
|
||||
eau/verre/métal, OFF/zéro, lune, bloom, rechargement et redimensionnement.
|
||||
Journaux : `build/beta148-ssr-vulkan.log`, `build/beta148-ssr-opengl.log`.
|
||||
Sous OpenGL, 146 680 pixels éclaircis et zéro pixel quasi blanc dans la scène
|
||||
solaire. La lune reste visible sans saturation (35 865 pixels modifiés).
|
||||
Preuves : `build/evidence/beta148-ssr-vulkan/` et
|
||||
`build/evidence/beta148-ssr-opengl/`.
|
||||
|
||||
- Suites PBR/préférences Vulkan et OpenGL réussies : défaut 50 %, migration
|
||||
de l’ancien 80 %, conservation d’un nouveau choix explicite 80 %, valeurs
|
||||
personnalisées, FR/EN, proximité, OFF/zéro, intensités et rechargement.
|
||||
Journaux : `build/beta148-pbr-vulkan.log`, `build/beta148-pbr-opengl.log`.
|
||||
- `./gradlew check build` dans l’instantané isolé : 252 tests serveur,
|
||||
229 réussis, 23 échecs. Exactement les mêmes identifiants que beta.147,
|
||||
aucun nouvel échec. Journal : `build/beta148-check-build-isolated.log` ;
|
||||
comparaison : `build/beta148-server-failures.json`. Le contrôle global
|
||||
demeure donc en échec.
|
||||
- Assemblage final réussi : `./gradlew build assemblePack -x :sanctuary:check`,
|
||||
sans répéter les contrôles purs et serveur déjà exécutés dans l’instantané.
|
||||
Journal : `build/beta148-build-pack.log`. L’exclusion ne constitue pas
|
||||
une réussite du contrôle complet.
|
||||
|
||||
## Artefact
|
||||
|
||||
[Sanctuary-beta.148.mrpack](../build/Sanctuary-beta.148.mrpack), également copié
|
||||
à côté des autres versions dans `sanctuary-beta/build/`. Archive ZIP vérifiée,
|
||||
un seul JAR Sanctuary beta.148 identique au JAR construit, shaders finaux
|
||||
identiques aux sources, textes FR/EN 50 % et dépendances exactes vérifiés.
|
||||
Taille : 10 520 050 octets. SHA-256 :
|
||||
`597cc2a96eabc3cf0ebf8ac59b4f94cf7cabf0c773e1dce13c10736ca6fcba38`.
|
||||
Reçu : `build/beta148-artifacts.json`.
|
||||
Aucune publication ni installation Prism effectuée.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Vérifier Sanctuary
|
||||
|
||||
## Cible graphique — décision du 18 septembre 2026
|
||||
|
||||
Les nouvelles validations graphiques Sanctuary utilisent **Vulkan uniquement**.
|
||||
OpenGL est abandonné comme cible de validation à la demande du créateur.
|
||||
Les anciens résultats OpenGL ci-dessous sont historiques et ne constituent
|
||||
pas un engagement de compatibilité. Ne plus programmer de suite OpenGL.
|
||||
|
||||
|
||||
Utiliser Java 25 et les dépendances épinglées dans `gradle.properties`.
|
||||
|
||||
```sh
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ loom_version=1.17.20
|
||||
fabric_api_version=0.160.5+26.3
|
||||
|
||||
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
|
||||
mod_version=beta.146
|
||||
pack_version=beta.146
|
||||
mod_version=beta.157
|
||||
pack_version=beta.157
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -258,6 +258,15 @@ tasks.named('processGametestResources') {
|
||||
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
|
||||
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
|
||||
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
|
||||
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
|
||||
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryPause155ClientTests', pause155Client)
|
||||
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
|
||||
def community154Client = providers.gradleProperty('sanctuaryCommunity154ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryCommunity154ClientTests', community154Client)
|
||||
if (community154Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community154ClientChecks') }
|
||||
def menus083Client = providers.gradleProperty('sanctuaryMenus083ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryMenus083ClientTests', menus083Client)
|
||||
if (menus083Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Menus083ClientChecks') }
|
||||
@@ -487,6 +496,8 @@ tasks.named('processGametestResources') {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.10'
|
||||
include('org.mariadb.jdbc:mariadb-java-client:3.5.10')
|
||||
implementation project(':jei')
|
||||
include project(':jei')
|
||||
implementation project(':demeure')
|
||||
@@ -1211,3 +1222,19 @@ tasks.register('seasonal105Smoke', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.weather.Seasonal105Smoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('seasonal105Smoke') }
|
||||
|
||||
tasks.register('community154Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.community.CommunitySmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('community154Smoke') }
|
||||
|
||||
tasks.register('community154Interop', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.community.CommunityInterop'
|
||||
args(providers.gradleProperty('communityInteropPhase').getOrElse('seed'))
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import java.util.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.components.events.*;
|
||||
import net.minecraft.client.gui.screens.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
|
||||
/** Real screens and server packets on an isolated flat development world. */
|
||||
public final class Community154ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean outage=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_OUTAGE"));
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
if(database) try {
|
||||
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
|
||||
var config=new fr.koka.sanctuary.community.CommunityConfig("database",UUID.randomUUID().toString(),System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"),"root","SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
java.nio.file.Files.createDirectories(path.getParent());
|
||||
java.nio.file.Files.writeString(path,new com.google.gson.Gson().toJson(config));
|
||||
} catch(Exception error) { throw new AssertionError(error); }
|
||||
var intro = new AtomicBoolean(true);
|
||||
c.runOnClient(m -> { m.options.renderDistance().set(5); m.options.simulationDistance().set(5); m.options.pauseOnLostFocus=false; IntroClient.setQuickTestIntro(true); });
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m -> { if (intro.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
|
||||
Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
|
||||
press(screen,"sanctuary.progression.enter"); intro.set(false);
|
||||
}});
|
||||
try (var world = c.worldBuilder().adjustSettings(s -> { s.setName("Community154 "+UUID.randomUUID()); s.setSeed("154"); s.setAllowCommands(false); s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL); s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow()); }).create()) {
|
||||
c.waitFor(m -> ProgressionClient.active() && m.player!=null && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
|
||||
world.getServer().runOnServer(s -> s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
|
||||
if(outage) {
|
||||
c.runOnClient(m -> m.gui.setScreen(new PauseScreen(true)));
|
||||
c.waitFor(m -> !CommunityClient.pending() && CommunityClient.overviewError.equals("unavailable"),300);
|
||||
c.takeScreenshot("community154-database-unavailable");
|
||||
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",true); edit(m.gui.screen()).setValue("Brouillon panne"); body(m.gui.screen()).setValue("Texte conservé"); press(m.gui.screen(),"sanctuary.community.publish"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.unavailable"),300);
|
||||
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("Texte conservé"),"Outage draft retained"); check(CommunityClient.overview.isEmpty(),"No false cached acknowledgement"); });
|
||||
world.getServer().runOnServer(s -> check(!java.nio.file.Files.exists(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json")),"No file fallback"));
|
||||
System.out.println("COMMUNITY154_OUTAGE_PASS: visible error, draft preserved, no file fallback");
|
||||
c.runOnClient(m -> m.gui.setScreen(null));
|
||||
return;
|
||||
}
|
||||
for (String locale : List.of("fr_fr","en_us")) for (int scale : List.of(2,3)) {
|
||||
c.runOnClient(m -> { m.getWindow().setWindowed(1280,800); m.options.guiScale().set(scale); m.resizeGui(); m.getLanguageManager().setSelected(locale); m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); m.gui.setScreen(new PauseScreen(true)); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && !CommunityClient.overview.isEmpty(),300);
|
||||
c.runOnClient(m -> { check(CommunityClient.overview.get("mode").getAsString().equals(database?"database":"file"),"Expected storage adapter"); press(m.gui.screen(),"sanctuary.community.article"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.empty"),300);
|
||||
c.takeScreenshot("community154-list-"+locale+"-"+scale);
|
||||
c.runOnClient(m -> m.gui.setScreen(new PauseScreen(true))); c.waitTicks(5);
|
||||
c.takeScreenshot("community154-pause-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m -> { m.options.guiScale().set(2); m.resizeGui(); m.getLanguageManager().setSelected("fr_fr"); m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); CommunityClient.open(new PauseScreen(true),"notice","",true); });
|
||||
c.waitFor(m -> !CommunityClient.pending(),250);
|
||||
c.runOnClient(m -> { edit(m.gui.screen()).setValue("La passerelle 🌳"); body(m.gui.screen()).setValue("Du bois et des bras.\nQui vient construire ?"); press(m.gui.screen(),"sanctuary.community.publish"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reply"),300);
|
||||
c.takeScreenshot("community154-published");
|
||||
c.waitTicks(25);
|
||||
c.runOnClient(m -> { body(m.gui.screen()).setValue("Je viens ce soir !"); press(m.gui.screen(),"sanctuary.community.reply"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && text(m.gui.screen(),"Je viens ce soir !"),300);
|
||||
c.takeScreenshot("community154-reply");
|
||||
c.runOnClient(m -> check(widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).count()==2,"Both conversation participants have faces"));
|
||||
c.waitTicks(25);
|
||||
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
|
||||
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.DRAFTS.computeIfAbsent("new:article",unused->new CommunityClient.Draft()).photo=testPhoto(); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
|
||||
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
|
||||
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
|
||||
c.runOnClient(m -> m.gui.screen().onClose()); c.waitTicks(30);
|
||||
c.runOnClient(m -> check(m.gui.screen() instanceof PauseScreen,"Late response does not reopen screen"));
|
||||
System.out.println("COMMUNITY154_CLIENT_MODE="+(database?"database":"file"));
|
||||
System.out.println("COMMUNITY154_CLIENT_PASS: pause FR/EN GUI 2/3, publication, reply, close, refused draft, late response");
|
||||
c.runOnClient(m -> m.gui.setScreen(null));
|
||||
} finally { intro.set(false); }
|
||||
}
|
||||
static String testPhoto() {try {return fr.koka.sanctuary.community.CommunityPhoto.encode(new java.awt.image.BufferedImage(16,9,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}catch(Exception e){throw new AssertionError(e);}}
|
||||
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
|
||||
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
|
||||
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
|
||||
static void press(Screen s,String key) { widgets(s).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)).findFirst().orElseThrow(() -> new AssertionError("Missing "+key)).onPress(enter()); }
|
||||
static EditBox edit(Screen s) { return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow(); }
|
||||
static MultiLineEditBox body(Screen s) { return widgets(s).filter(MultiLineEditBox.class::isInstance).map(MultiLineEditBox.class::cast).findFirst().orElseThrow(); }
|
||||
static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN,0,0); }
|
||||
static void check(boolean ok,String message) { if (!ok) throw new AssertionError(message); }
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import fr.koka.sanctuary.community.*;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
|
||||
public final class Pause155ClientChecks implements FabricClientGameTest {
|
||||
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
var intro=new AtomicBoolean(true);
|
||||
try {
|
||||
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
|
||||
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
java.nio.file.Files.createDirectories(path.getParent());java.nio.file.Files.writeString(path,new com.google.gson.Gson().toJson(config));
|
||||
} catch(Exception e) {throw new AssertionError(e);}
|
||||
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen s) {
|
||||
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());press(s,"sanctuary.progression.enter");intro.set(false);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Pause155 "+UUID.randomUUID());s.setSeed("155");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
|
||||
c.waitFor(m->ProgressionClient.active() && m.player!=null && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
|
||||
world.getServer().runOnServer(s->{
|
||||
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.setExperienceLevels(20);
|
||||
check(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Atlas unlocked");ProgressionService.send(p,"");
|
||||
for(int dz=-2;dz<=2;dz++)for(int dx=-2;dx<=2;dx++) {
|
||||
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(p.level(),p.chunkPosition().x()+dx,p.chunkPosition().z()+dz);
|
||||
check(tile!=null,"Loaded fixture tile");fr.koka.sanctuary.atlas.AtlasService.observations(p).observe(tile);
|
||||
}
|
||||
try {
|
||||
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
|
||||
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
|
||||
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
|
||||
} catch(Exception e) {throw new AssertionError(e);}
|
||||
});
|
||||
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(40);
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("Gazette — ")).count()==10 && widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("La passerelle — ")).count()==10 && !CommunityClient.pending(),400);
|
||||
c.waitFor(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>4096,400);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();
|
||||
check(s instanceof PauseAtlasAccess,"Pause atlas access");
|
||||
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.history","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
|
||||
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=40&&b.getY()<s.height-76).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
|
||||
check(navigation.size()==6,"Six real navigation buttons");
|
||||
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
|
||||
var options=nativeButton(s,"menu.options");var worldOptions=nativeButton(s,"options.worldOptions.button");var quit=nativeButton(s,"menu.returnToMenu");var resume=nativeButton(s,"menu.returnToGame");
|
||||
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-8,"Options and quit stacked at bottom left");
|
||||
var feed=areas(s).getFirst();
|
||||
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
|
||||
for(var e:s.children())if(e instanceof Button b && b.getX()==8 && b.getY()<options.getY())check(b.getBottom()<options.getY(),"Navigation does not overlap options");
|
||||
check(!has(s,"gui.advancements") && !has(s,"gui.stats") && !has(s,"menu.game"),"Old grid removed");
|
||||
for(var e:s.children())if(e instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Widget bounds "+w.getClass().getSimpleName()+" "+w.getX()+","+w.getY()+" "+w.getWidth()+"x"+w.getHeight()+" / "+s.width+"x"+s.height);
|
||||
var faces=widgets(s).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).toList();
|
||||
check(faces.size()==10,"Each Gazette article has a face");
|
||||
var first=firstPost(s,"Gazette — ");check(faces.getFirst().getY()==first.getY() && faces.getFirst().getRight()+5==first.getX(),"Face is left of the title on the same row");
|
||||
var areas=areas(s);check(areas.size()==2,"Two independent feeds");
|
||||
double other=areas.get(1).scrollAmount();check(s.mouseScrolled(areas.get(0).getX()+5,areas.get(0).getY()+5,0,-5),"Gazette wheel handled");check(areas.get(0).scrollAmount()>0&&areas.get(1).scrollAmount()==other,"Gazette scroll isolated");
|
||||
check(s.mouseScrolled(areas.get(1).getX()+5,areas.get(1).getY()+5,0,-5),"Notice wheel handled");check(areas.get(1).scrollAmount()>0,"Notices scroll");
|
||||
areas.forEach(a->a.setScrollAmount(0));
|
||||
});
|
||||
if(locale.equals("fr_fr") && scale==2) {
|
||||
var time=new java.util.concurrent.atomic.AtomicLong();
|
||||
world.getServer().runOnServer(s->{check(((net.minecraft.client.server.IntegratedServer)s).isPaused(),"Solo remains paused");time.set(s.overworld().getGameTime());});
|
||||
c.waitTicks(30);world.getServer().runOnServer(s->check(s.overworld().getGameTime()==time.get(),"Reading atlas does not advance world"));
|
||||
}
|
||||
c.takeScreenshot("pause155-"+locale+"-"+scale+(database?"-database":"-file"));
|
||||
}
|
||||
c.runOnClient(m->{var areas=areas(m.gui.screen());areas.getFirst().setScrollAmount(areas.getFirst().maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("Gazette — ")).count()==13&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{check(areas(m.gui.screen()).getFirst().scrollAmount()>0,"Appending preserves scroll");firstPost(m.gui.screen(),"Gazette — ").onPress(enter());});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{var a=areas(m.gui.screen()).getFirst();check(a.maxScrollAmount()>300,"Long article scrolls");m.gui.screen().mouseScrolled(a.getX()+5,a.getY()+5,0,-1000);check(a.scrollAmount()==a.maxScrollAmount(),"Reach article end and reply");});
|
||||
c.takeScreenshot("pause155-long-article");
|
||||
c.runOnClient(m->{body(m.gui.screen()).setValue("Présent pour le marché !");press(m.gui.screen(),"sanctuary.community.reply");});
|
||||
c.waitFor(m->text(m.gui.screen(),"Présent pour le marché !")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->w.getMessage().getString().startsWith("La passerelle — ")),300);
|
||||
c.runOnClient(m->firstPost(m.gui.screen(),"La passerelle — ").onPress(enter()));
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{var a=areas(m.gui.screen()).getFirst();check(a.maxScrollAmount()>300,"Long notice scrolls");m.gui.screen().mouseScrolled(a.getX()+5,a.getY()+5,0,-1000);check(a.scrollAmount()==a.maxScrollAmount(),"Reach notice end");m.gui.setScreen(new PauseScreen(true));});
|
||||
c.waitFor(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>4096,400);
|
||||
c.runOnClient(m->{check(((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>0,"Paused map renders personal surveys");press(m.gui.screen(),"menu.options");check(m.gui.screen() instanceof net.minecraft.client.gui.screens.options.OptionsScreen,"Native options");m.gui.screen().onClose();press(m.gui.screen(),"options.worldOptions.button");check(m.gui.screen() instanceof WorldOptionsScreen,"Native world options");m.gui.screen().onClose();press(m.gui.screen(),"menu.returnToGame");check(m.gui.screen()==null,"Resume game");});
|
||||
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(25);
|
||||
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(enter()));
|
||||
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen,100);
|
||||
c.runOnClient(m->m.gui.screen().onClose());c.waitTicks(25);
|
||||
c.runOnClient(m->{check(m.gui.screen() instanceof PauseScreen,"Atlas returns to pause");m.gui.setScreen(null);});
|
||||
System.out.println("PAUSE155_CLIENT_PASS mode="+(database?"database":"file")+" FR/EN scales 2/3/4, independent wheel scrolling, pagination, long article/notice, reply, native footer, atlas return");
|
||||
} finally {intro.set(false);}
|
||||
}
|
||||
private static Button nativeButton(Screen s,String key) {return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->CommunityPausePanels.key(b).equals(key)).findFirst().orElseThrow();}
|
||||
private static List<AbstractScrollArea> areas(Screen s) {return widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).toList();}
|
||||
private static AbstractButton firstPost(Screen s,String prefix) {return widgets(s).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow();}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import fr.koka.sanctuary.community.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Screenshot selection and article round trip through actual client/server packets. */
|
||||
public final class Photos157ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
var intro=new AtomicBoolean(true);
|
||||
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
|
||||
try {
|
||||
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
|
||||
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
|
||||
} catch(Exception e){throw new AssertionError(e);}
|
||||
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
|
||||
try {
|
||||
Files.createDirectories(folder.get());
|
||||
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
var random=new Random(157);
|
||||
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
|
||||
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
|
||||
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
|
||||
}catch(Exception e){throw new AssertionError(e);}
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
|
||||
Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
|
||||
press(screen,"sanctuary.progression.enter");intro.set(false);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
|
||||
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
|
||||
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{
|
||||
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
CommunityClient.open(new PauseScreen(true),"article","",true);
|
||||
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
|
||||
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
|
||||
press(m.gui.screen(),"sanctuary.community.photo_choose");
|
||||
});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
|
||||
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
|
||||
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
|
||||
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
|
||||
});
|
||||
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
|
||||
c.runOnClient(m->m.gui.screen().onClose());
|
||||
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
|
||||
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
|
||||
c.takeScreenshot("photos157-preview");
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
|
||||
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
|
||||
c.runOnClient(m->check(widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),"Published photo displayed"));
|
||||
c.takeScreenshot("photos157-article"+(database?"-database":"-file"));
|
||||
world.getServer().runOnServer(s->{try {
|
||||
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
var posts=store.list("article","",0).entries();check(posts.size()==1,"Single publication");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
|
||||
}catch(Exception e){throw new AssertionError(e);}});
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("PHOTOS157_CLIENT_PASS mode="+(database?"database":"file"));
|
||||
} finally {intro.set(false);}
|
||||
}
|
||||
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
|
||||
}
|
||||
+105
-5
@@ -27,6 +27,8 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->check(analyses==ProceduralPbr.analyses()&&ProceduralPbr.materialCount()>0,"Source textures are cached, not regenerated every frame"));
|
||||
unchanged(on,capture(c,null),"Static PBR has no animated noise");
|
||||
look(c,server,.00002f,40.00002f);frames(c);unchanged(on,capture(c,null),"PBR tiny camera motion");look(c,server,0,40);frames(c);
|
||||
closeRange(c,server);
|
||||
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
|
||||
c.runOnClient(m->VanillaLight.setPbrIntensity(20));frames(c);var low=difference(off,capture(c,"pbr-20"));
|
||||
check(delta.total>low.total*1.3,"PBR strength affects actual GPU output");
|
||||
c.runOnClient(m->VanillaLight.setPbrIntensity(100));frames(c);var high=difference(off,capture(c,"pbr-100"));check(high.total>delta.total*1.2,"Strong PBR visibly increases relief");
|
||||
@@ -48,10 +50,97 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m->m.getWindow().setWindowed(oldWidth,oldHeight));c.waitFor(m->m.gameRenderer.mainRenderTarget().width==on.width(),200);frames(c);
|
||||
c.runOnClient(m->{VanillaLight.setPixelShadows(true);VanillaLight.setSsgi(true);VanillaLight.setBloom(true);VanillaLight.setOreEmission(true);VanillaLight.setEdgeHighlights(true);});
|
||||
c.waitFor(m->PixelShadows.applied()&&PixelShadows.ready()&&ScreenSpaceGI.applied()&&Bloom.applied()&&EdgeHighlights.applied()&&ProceduralPbr.applied(),1200);frames(c);capture(c,"pbr-combined");
|
||||
matteGround(c,server,Blocks.OAK_LEAVES,"leaves",true);
|
||||
matteGround(c,server,Blocks.DIRT,"dirt",false);
|
||||
matteGround(c,server,Blocks.OAK_PLANKS,"oak-planks",false);
|
||||
matteGround(c,server,Blocks.STRIPPED_SPRUCE_LOG,"stripped-spruce",false);
|
||||
darkStone(c,server);
|
||||
netherRoundTrip(c,server);
|
||||
c.runOnClient(m->VanillaLight.setEnabled(false));c.waitTicks(8);c.runOnClient(m->check(!ProceduralPbr.allocated()&&ProceduralPbr.materialCount()==0,"Global OFF releases PBR"));
|
||||
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setPbr(false);VanillaLight.setSsgi(false);});
|
||||
System.out.println("PBR134_PASS normals; profiles; texture cache; directional lighting; intensity; OFF/zero; tiny camera; reload; resize; combined; default="+delta+" high="+high+" directional="+directions);
|
||||
}
|
||||
private static void darkStone(ClientGameTestContext c,TestServerContext server){
|
||||
server.runOnServer(s->{for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),Blocks.OBSIDIAN.defaultBlockState());});
|
||||
camera(c,server,10.5,69,5.5,65);look(c,server,0,90);settle(c);
|
||||
enable(c,false);var off=capture(c,"pbr151-obsidian-face-off");enable(c,true);var on=capture(c,"pbr151-obsidian-face-on");
|
||||
double gain=0;int samples=0,relief=0;
|
||||
for(int y=off.height()/4;y<off.height()*3/4;y++)for(int x=off.width()/4;x<off.width()*3/4;x++){
|
||||
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i];
|
||||
double before=((a>>16&255)+(a>>8&255)+(a&255))/3.0,after=((b>>16&255)+(b>>8&255)+(b&255))/3.0;
|
||||
gain+=Math.max(0,after-before);samples++;if(Math.abs(after-before)>1)relief++;
|
||||
}
|
||||
System.out.println("PBR151_OBSIDIAN samples="+samples+" meanBrightening="+gain/samples+" relief="+relief);
|
||||
check(samples>1000&&gain/samples<18,"Dark stone keeps its dark face without amplified white sheen");
|
||||
check(relief>100,"Obsidian PBR remains visible");
|
||||
look(c,server,0,20);settle(c);capture(c,"pbr151-obsidian-grazing");
|
||||
}
|
||||
private static void netherRoundTrip(ClientGameTestContext c,TestServerContext server){
|
||||
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setColoredLights(true);VanillaLight.setPixelShadows(true);});
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
|
||||
p.getInventory().setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,7));
|
||||
var overflow=fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p);
|
||||
overflow.setItem(0,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.EMERALD,3));
|
||||
overflow.setItem(17,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.GOLD_INGOT,5));
|
||||
p.inventoryMenu.broadcastFullState();});
|
||||
for(String dimension:new String[]{"the_nether","overworld","the_nether","overworld"}){
|
||||
server.runOnServer(s->command(s,"execute in minecraft:"+dimension+" run tp @a 10.5 100 5.5"));
|
||||
c.waitFor(m->m.level!=null&&m.player!=null&&m.level.dimension().identifier().getPath().equals(dimension)&&m.gui.screen()==null,2400);
|
||||
c.waitTicks(40);
|
||||
c.runOnClient(m->{
|
||||
check(m.player.inventoryMenu.slots.size()==67,"Inventory has exactly 46 native, 18 extra and 3 accessory slots");
|
||||
check(m.player.getInventory().getItem(0).is(net.minecraft.world.item.Items.DIAMOND)&&m.player.getInventory().getItem(0).getCount()==7,"Native stack survives transfer");
|
||||
var overflow=fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(m.player);
|
||||
check(overflow.getItem(0).is(net.minecraft.world.item.Items.EMERALD)&&overflow.getItem(0).getCount()==3,"First overflow stack survives transfer");
|
||||
check(overflow.getItem(17).is(net.minecraft.world.item.Items.GOLD_INGOT)&&overflow.getItem(17).getCount()==5,"Last overflow stack survives transfer");
|
||||
});
|
||||
capture(c,"nether151-"+dimension);
|
||||
server.runOnServer(s->check(s.getPlayerList().getPlayers().size()==1,"Player stays connected after dimension transfer"));
|
||||
System.out.println("NETHER151_TRANSFER_PASS "+dimension);
|
||||
}
|
||||
}
|
||||
private static void matteGround(ClientGameTestContext c,TestServerContext server,net.minecraft.world.level.block.Block block,String name,boolean leaves){
|
||||
c.runOnClient(m->{VanillaLight.setSsr(false);VanillaLight.setPbrIntensity(50);VanillaLight.setPixelShadows(false);VanillaLight.setSsgi(false);VanillaLight.setBloom(false);VanillaLight.setOreEmission(false);VanillaLight.setEdgeHighlights(false);});
|
||||
server.runOnServer(s->{if(leaves)command(s,"time set 1000");for(int x=-5;x<=24;x++)for(int z=-5;z<=24;z++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,64,z),leaves?block.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT,true):block.defaultBlockState());});
|
||||
camera(c,server,10.5,69,5.5,65);look(c,server,0,40);settle(c);
|
||||
enable(c,false);var off=capture(c,"pbr150-"+name+"-off");enable(c,true);var on=capture(c,"pbr150-"+name+"-on");
|
||||
double nativeChroma=0,pbrChroma=0;int samples=0,relief=0;
|
||||
for(int y=off.height()/3;y<off.height()*9/10;y++)for(int x=off.width()/10;x<off.width()*9/10;x++){
|
||||
int i=y*off.width()+x,a=off.pixels()[i],b=on.pixels()[i],r=a>>16&255,g=a>>8&255,blue=a&255;
|
||||
if(leaves?(g<30||g<r*1.1||g<blue*1.1):(r<30||r<g*1.05||g<blue*1.1))continue;
|
||||
nativeChroma+=chroma(a);pbrChroma+=chroma(b);samples++;
|
||||
if(Math.abs((b>>16&255)-r)+Math.abs((b>>8&255)-g)+Math.abs((b&255)-blue)>6)relief++;
|
||||
}
|
||||
double retained=pbrChroma/nativeChroma;
|
||||
System.out.println("PBR150_MATTE material="+name+" samples="+samples+" chromaRetained="+retained+" relief="+relief);
|
||||
check(samples>1000&&retained>.985,name+" preserves native colour without specular veil: "+retained);
|
||||
check(relief>100,name+" normal relief remains visible");
|
||||
}
|
||||
private static double chroma(int pixel){
|
||||
int r=pixel>>16&255,g=pixel>>8&255,b=pixel&255,max=Math.max(r,Math.max(g,b)),min=Math.min(r,Math.min(g,b));
|
||||
return (max-min)/(double)Math.max(1,max);
|
||||
}
|
||||
private static void closeRange(ClientGameTestContext c,TestServerContext server){
|
||||
int failures=0;
|
||||
c.runOnClient(m->VanillaLight.setPbrIntensity(80));
|
||||
for(double distance:new double[]{.12,.25,.5,1.0}){
|
||||
double eye=c.computeOnClient(m->(double)m.player.getEyeHeight());
|
||||
camera(c,server,4.5,65-eye+distance,5.5,65);look(c,server,17,55);settle(c);frames(c);
|
||||
var before=capture(c,"pbr147-close-"+distance);
|
||||
look(c,server,17.00002f,55.00002f);frames(c);var after=capture(c,"pbr147-close-moved-"+distance);
|
||||
int changed=0,count=0;
|
||||
for(int y=before.height()/10;y<before.height()*9/10;y++)for(int x=before.width()/10;x<before.width()*9/10;x++){
|
||||
int i=y*before.width()+x,a=before.pixels()[i],b=after.pixels()[i];count++;
|
||||
if(Math.abs((a>>16&255)-(b>>16&255))+Math.abs((a>>8&255)-(b>>8&255))+Math.abs((a&255)-(b&255))>12)changed++;
|
||||
}
|
||||
System.out.println("PBR147_CLOSE distance="+distance+" changed="+changed+" samples="+count);
|
||||
if(changed>count*.001)failures++;
|
||||
}
|
||||
c.runOnClient(m->VanillaLight.setPbrIntensity(50));
|
||||
check(failures==0,"Close PBR stays stable under subpixel motion: failing distances="+failures);
|
||||
}
|
||||
private static void materials(){
|
||||
int[] flat=new int[16];Arrays.fill(flat,0xFF808080);var profile=PbrMaterials.profile("bricks");
|
||||
for(int p:PbrMaterials.generate(flat,4,4,profile))check((p>>8&0xFFFF)==0x8080,"Flat source has neutral normal");
|
||||
@@ -61,27 +150,38 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
|
||||
check(PbrMaterials.profile("water").metal()==0&&PbrMaterials.profile("glass").metal()==0,"Water and glass stay dielectric");
|
||||
int[] glass={0x00FFFFFF,0x00000000,0x00000000,0x00FFFFFF};
|
||||
for(int p:PbrMaterials.generate(glass,2,2,PbrMaterials.profile("glass")))check((p>>8&0xFFFF)==0x8080,"Transparent glass texels fall back to geometric normal");
|
||||
for(String soil:new String[]{"dirt","coarse_dirt","rooted_dirt","grass_block","dirt_path","farmland","podzol","mycelium"})
|
||||
check(PbrMaterials.profile(soil).roughness()>=.98f&&PbrMaterials.profile(soil).metal()==0,"Dry soil has a fully matte profile: "+soil);
|
||||
for(String wood:new String[]{"oak_planks","spruce_stairs","dark_oak_slab","stripped_spruce_log","stripped_crimson_stem","warped_hyphae","bamboo_mosaic","pale_oak_trapdoor"})
|
||||
check(PbrMaterials.profile(wood).roughness()>=.98f&&PbrMaterials.profile(wood).metal()==0,"Wood remains fully matte: "+wood);
|
||||
check(PbrMaterials.profile("crimson_nylium").roughness()<.9f,"Nether ground is not classified as wood");
|
||||
check(PbrMaterials.profile("oak_planks").roughness()>PbrMaterials.profile("polished_andesite").roughness(),"Wood remains rougher than polished stone");
|
||||
}
|
||||
private static void preferences(){try{
|
||||
var p=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");Files.writeString(p,"{\"ssgi\":true,\"ssgiIntensity\":35,\"bloom\":false}");forgetPreferences();
|
||||
check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==80&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
|
||||
check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==50&&VanillaLight.ssgi()&&!VanillaLight.bloom(),"Missing PBR setting defaults ON while preserving explicit old choices");
|
||||
Files.writeString(p,"{\"pbrIntensity\":80}");forgetPreferences();
|
||||
check(VanillaLight.pbrIntensity()==50,"Previous 80 percent baseline migrates to 50 once");
|
||||
VanillaLight.setPbrIntensity(80);forgetPreferences();check(VanillaLight.pbrIntensity()==80,"New explicit 80 percent choice persists after migration");
|
||||
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setPbr(true);VanillaLight.setPbrIntensity(n);forgetPreferences();check(VanillaLight.pbr()&&VanillaLight.pbrIntensity()==Math.clamp(n,0,100),"PBR preference persistence/clamp");}
|
||||
check(VanillaLight.pbrDistance()==32,"Shared distance default");
|
||||
check(VanillaLight.pbrDistance()==64&&VanillaLight.ssrDistance()==64,"Independent distances default to 64");
|
||||
check(!VanillaLight.naturalWater(false)&&VanillaLight.naturalWater(true),"World-specific water defaults");
|
||||
for(int n:new int[]{16,32,64,128}){VanillaLight.setPbrDistance(n);forgetPreferences();check(VanillaLight.pbrDistance()==n,"Shared distance persistence");}
|
||||
for(int n:new int[]{16,32,64,128,256}){VanillaLight.setPbrDistance(n);forgetPreferences();check(VanillaLight.pbrDistance()==n&&VanillaLight.ssrDistance()==64,"PBR distance changes independently");}
|
||||
for(int n:new int[]{16,32,64,128,256}){VanillaLight.setSsrDistance(n);forgetPreferences();check(VanillaLight.ssrDistance()==n&&VanillaLight.pbrDistance()==256,"SSR distance changes independently");}
|
||||
VanillaLight.setSsrDistance(64);
|
||||
for(int n:new int[]{0,1,2,4,8}){VanillaLight.setWaterBlendRadius(n);forgetPreferences();check(VanillaLight.waterBlendRadius()==n,"Water blend persistence");}
|
||||
VanillaLight.setPbrDistance(32);VanillaLight.setWaterBlendRadius(4);
|
||||
check(!VanillaLight.ssr()&&VanillaLight.ssrIntensity()==20,"Missing SSR defaults OFF at 20 percent");
|
||||
check(VanillaLight.ssr()&&VanillaLight.ssrIntensity()==20,"Missing SSR defaults ON at 20 percent");
|
||||
for(int n:new int[]{-1,0,50,100,101}){VanillaLight.setSsr(true);VanillaLight.setSsrIntensity(n);forgetPreferences();check(VanillaLight.ssr()&&VanillaLight.ssrIntensity()==Math.clamp(n,0,100),"SSR preference persistence/clamp");}
|
||||
VanillaLight.setSsr(false);VanillaLight.setSsrIntensity(20);forgetPreferences();check(!VanillaLight.ssr(),"SSR OFF persists");
|
||||
var manager=net.minecraft.client.Minecraft.getInstance().getLanguageManager();var oldLanguage=manager.getSelected();
|
||||
for(String locale:new String[]{"fr_fr","en_us"}){
|
||||
manager.setSelected(locale);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
|
||||
for(String key:new String[]{"pbr_distance","pbr_distance_help","pbr_distance_value","water_blend","water_blend_help","water_blend_value","ssr","ssr_help","ssr_intensity","ssr_intensity_help","ssr_intensity_value"}){
|
||||
for(String key:new String[]{"ssr_distance","ssr_distance_help","ssr_distance_value","pbr_distance","pbr_distance_help","pbr_distance_value","water_blend","water_blend_help","water_blend_value","ssr","ssr_help","ssr_intensity","ssr_intensity_help","ssr_intensity_value"}){
|
||||
String full="sanctuary.shaders."+key;check(!net.minecraft.network.chat.Component.translatable(full,50).getString().equals(full),"SSR FR/EN "+full);
|
||||
}
|
||||
}
|
||||
check(net.minecraft.network.chat.Component.translatable("sanctuary.shaders.ssr").getString().equals("SSR"),"SSR label without experimental qualifier");
|
||||
manager.setSelected(oldLanguage);manager.onResourceManagerReload(net.minecraft.client.Minecraft.getInstance().getResourceManager());
|
||||
Files.writeString(p,"{\"naturalWater\":false,\"pbrIntensity\":37}");forgetPreferences();
|
||||
check(!VanillaLight.naturalWater(false)&&!VanillaLight.naturalWater(true)&&VanillaLight.pbrIntensity()==37,"Legacy water OFF and personal PBR intensity are preserved");
|
||||
|
||||
+62
-5
@@ -45,25 +45,81 @@ final class PixelReflectionsClientChecks {
|
||||
check(torsoChange<torsoPixels*.01,"SSR does not draw through the visible F5 torso: "+torsoChange+" / "+torsoPixels);
|
||||
c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));c.waitTicks(20);
|
||||
server.runOnServer(s->command(s,"gamemode spectator @a"));c.waitTicks(10);
|
||||
for(int distance:new int[]{16,64,128,32}){
|
||||
c.runOnClient(m->VanillaLight.setPbrDistance(distance));settle(c);frames(c);capture(c,"ssr146-distance-"+distance);
|
||||
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"Both material passes share distance "+distance));
|
||||
for(int distance:new int[]{16,64,128,256,32}){
|
||||
c.runOnClient(m->{VanillaLight.setPbrDistance(distance);VanillaLight.setSsrDistance(256);});settle(c);frames(c);capture(c,"ssr146-distance-"+distance);
|
||||
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"Independent material distances render at PBR "+distance+" / SSR 256"));
|
||||
}
|
||||
|
||||
c.runOnClient(m->{VanillaLight.setPbrIntensity(80);VanillaLight.setSsrIntensity(20);});
|
||||
for(int distance:new int[]{16,64,256}){
|
||||
c.runOnClient(m->{VanillaLight.setPbrDistance(256);VanillaLight.setSsrDistance(distance);});settle(c);frames(c);
|
||||
capture(c,"ssr149-independent-"+distance);
|
||||
c.runOnClient(m->check(ProceduralPbr.applied()&&PixelReflections.applied(),"PBR 256 remains active with independent SSR "+distance));
|
||||
}
|
||||
c.runOnClient(m->VanillaLight.setPbrDistance(32));
|
||||
c.runOnClient(m->VanillaLight.setSsrDistance(32));
|
||||
c.runOnClient(m->{VanillaLight.setPbrIntensity(50);VanillaLight.setSsrIntensity(20);});
|
||||
camera(c,server,.5,68,10,70);settle(c);frames(c);
|
||||
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var solarOff=capture(c,"ssr148-solar-water-off");
|
||||
c.runOnClient(m->VanillaLight.setPbr(true));frames(c);
|
||||
var solar=capture(c,"ssr146-solar-water");
|
||||
int white=0;for(int pixel:solar.pixels())if((pixel>>16&255)>235&&(pixel>>8&255)>235&&(pixel&255)>235)white++;
|
||||
check(white>300,"Solar reflection has a broad near-white footprint at PBR 80 / SSR 20 percent: "+white);
|
||||
int brightened=0;for(int i=0;i<solar.pixels().length;i++){
|
||||
int a=solarOff.pixels()[i],b=solar.pixels()[i];
|
||||
if((b>>16&255)+(b>>8&255)+(b&255)>(a>>16&255)+(a>>8&255)+(a&255)+24)brightened++;
|
||||
}
|
||||
check(brightened>5000,"Soft solar reflection remains broadly visible: "+brightened);
|
||||
check(white<solar.pixels().length*.005,"Soft solar reflection preserves texture instead of a white plateau: "+white);
|
||||
System.out.println("PBR148_SOLAR brightened="+brightened+" white="+white);
|
||||
System.out.println("SSR146_SOLAR_WHITE pixels="+white+" width="+solar.width()+" height="+solar.height());
|
||||
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-water-bloom");
|
||||
c.runOnClient(m->check(MaterialBloom.contribution()!=null,"Material light reaches bloom"));
|
||||
c.runOnClient(m->VanillaLight.setBloom(false));frames(c);
|
||||
c.runOnClient(m->check(MaterialBloom.contribution()==null,"Bloom OFF releases material halo"));
|
||||
server.runOnServer(s->command(s,"time set 18000"));c.waitTicks(15);
|
||||
c.runOnClient(m->VanillaLight.setSsr(false));c.waitTicks(8);
|
||||
var moon=capture(c,"ssr147-moon-water");
|
||||
int moonWhite=0;for(int pixel:moon.pixels())if((pixel>>16&255)>235&&(pixel>>8&255)>235&&(pixel&255)>235)moonWhite++;
|
||||
check(moonWhite<Math.max(10,white*.05),"Moon reflection stays much softer than the sun: "+moonWhite+" / "+white);
|
||||
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var moonOff=capture(c,"ssr147-moon-water-off");
|
||||
check(changed(moonOff,moon)>100,"Soft moon reflection remains visible");
|
||||
System.out.println("PBR147_MOON white="+moonWhite+" sunWhite="+white+" changed="+changed(moonOff,moon));
|
||||
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setBloom(true);});frames(c);capture(c,"ssr147-moon-water-bloom");
|
||||
c.runOnClient(m->VanillaLight.setBloom(false));
|
||||
server.runOnServer(s->command(s,"time set 1000"));c.waitTicks(10);
|
||||
var sunLook=c.computeOnClient(m->{var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);return new float[]{(float)Math.toDegrees(Math.atan2(-sun.x,sun.z)),(float)Math.toDegrees(Math.asin(sun.y))};});
|
||||
look(c,server,sunLook[0],sunLook[1]);frames(c);capture(c,"ssr146-solar-path-low-sun");
|
||||
c.runOnClient(m->VanillaLight.setBloom(true));frames(c);capture(c,"ssr146-solar-path-low-sun-bloom");
|
||||
for(int time:new int[]{2000,11500,12400}){
|
||||
c.runOnClient(m->{VanillaLight.setBloom(false);VanillaLight.setSsr(false);});
|
||||
server.runOnServer(s->command(s,"time set "+time));c.waitTicks(15);
|
||||
var solarLook=c.computeOnClient(m->{var sun=PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);return new float[]{(float)Math.toDegrees(Math.atan2(-sun.x,sun.z)),(float)Math.toDegrees(Math.asin(sun.y))};});
|
||||
look(c,server,solarLook[0],solarLook[1]);pbrFrames(c);
|
||||
var warm=capture(c,"ssr150-solar-"+time);
|
||||
c.runOnClient(m->VanillaLight.setPbr(false));c.waitTicks(8);var nativeWater=capture(c,"ssr150-solar-native-"+time);
|
||||
long red=0,green=0,blue=0;int samples=0;
|
||||
for(int y=warm.height()/3;y<warm.height()*9/10;y++)for(int x=warm.width()/3;x<warm.width()*2/3;x++){
|
||||
int i=y*warm.width()+x,a=nativeWater.pixels()[i],b=warm.pixels()[i];
|
||||
int dr=(b>>16&255)-(a>>16&255),dg=(b>>8&255)-(a>>8&255),db=(b&255)-(a&255);
|
||||
if(dr>3){red+=dr;green+=dg;blue+=db;samples++;}
|
||||
}
|
||||
System.out.println("PBR150_SOLAR_COLOUR time="+time+" pitch="+solarLook[1]+" samples="+samples+" red="+red+" green="+green+" blue="+blue);
|
||||
var tint=c.computeOnClient(m->SolarColour.tint(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState));
|
||||
float nativeFade=c.computeOnClient(m->m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunriseAndSunsetColor.w());
|
||||
System.out.println("PBR150_NATIVE_SUNSET time="+time+" fade="+nativeFade+" tint="+tint);
|
||||
check(samples>500,"Solar reflection remains visible: "+time);
|
||||
if(nativeFade>.1)check(red>green*1.1&&red>blue*1.1,"Reflection follows warm native horizon colour: "+time);
|
||||
if(nativeFade==0)check(tint.equals(new org.joml.Vector3f(1)),"No early tint before native sunset");
|
||||
c.runOnClient(m->{VanillaLight.setPbr(true);VanillaLight.setSsr(true);VanillaLight.setBloom(true);});frames(c);capture(c,"ssr150-solar-bloom-"+time);
|
||||
c.runOnClient(m->VanillaLight.setBloom(false));
|
||||
look(c,server,solarLook[0],-solarLook[1]);frames(c);var sunDisc=capture(c,"ssr150-sun-disc-"+time);
|
||||
long sr=0,sg=0,sb=0;int sunSamples=0;
|
||||
for(int y=sunDisc.height()/2-5;y<sunDisc.height()/2+5;y++)for(int x=sunDisc.width()/2-5;x<sunDisc.width()/2+5;x++){
|
||||
int pixel=sunDisc.pixels()[y*sunDisc.width()+x];sr+=pixel>>16&255;sg+=pixel>>8&255;sb+=pixel&255;sunSamples++;
|
||||
}
|
||||
System.out.println("PBR150_SUN_DISC time="+time+" red="+sr/sunSamples+" green="+sg/sunSamples+" blue="+sb/sunSamples);
|
||||
if(nativeFade>.1)check(sr>sb*1.05,"Sun disc follows native warm tint: "+time);
|
||||
look(c,server,solarLook[0],solarLook[1]);
|
||||
}
|
||||
c.runOnClient(m->{VanillaLight.setBloom(false);VanillaLight.setSsrIntensity(100);});
|
||||
server.runOnServer(s->command(s,"time set 6000"));c.waitTicks(10);
|
||||
camera(c,server,.5,68,0,70);look(c,server,0,12);settle(c);
|
||||
@@ -203,6 +259,7 @@ final class PixelReflectionsClientChecks {
|
||||
int i=y*a.width()+x,aa=a.pixels()[i],bb=b.pixels()[i];
|
||||
if(Math.abs((aa>>16&255)-(bb>>16&255))+Math.abs((aa>>8&255)-(bb>>8&255))+Math.abs((aa&255)-(bb&255))>8)n++;
|
||||
}return n;}
|
||||
private static void pbrFrames(ClientGameTestContext c){int start=c.computeOnClient(m->ProceduralPbr.renderedFrames());c.waitFor(m->ProceduralPbr.applied()&&ProceduralPbr.renderedFrames()>start+8,1200);}
|
||||
private static void frames(ClientGameTestContext c){int start=c.computeOnClient(m->PixelReflections.frames());c.waitFor(m->PixelReflections.applied()&&ProceduralPbr.ready()&&PixelReflections.frames()>start+8,1200);}
|
||||
private static void settle(ClientGameTestContext c){c.waitTicks(45);c.waitFor(m->m.levelRenderer.hasRenderedAllSections()&&ProceduralPbr.ready(),1200);c.waitTicks(10);}
|
||||
}
|
||||
|
||||
+1
-2
@@ -56,7 +56,6 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
|
||||
var info = com.mojang.blaze3d.systems.RenderSystem.getDevice().getDeviceInfo();
|
||||
String expected = System.getProperty("sanctuary.test.graphicsBackend", "vulkan");
|
||||
check(net.minecraft.client.PreferredGraphicsApi.DEFAULT.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.vulkan.VulkanBackend, "Default tries native Vulkan first");
|
||||
check(net.minecraft.client.PreferredGraphicsApi.OPENGL.getBackendsToTry()[0] instanceof com.mojang.renderpearl.backend.opengl.GlBackend, "Explicit OpenGL remains available");
|
||||
check(expected.isEmpty() || info.backendName().equalsIgnoreCase(expected),
|
||||
"Requested graphics backend must not silently fall back: " + expected + " / " + info.backendName());
|
||||
System.out.println("SHADER139_BACKEND " + info.backendName() + " / " + info.name()
|
||||
@@ -454,7 +453,7 @@ public final class PixelShadows122ClientChecks implements FabricClientGameTest {
|
||||
var path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
|
||||
Files.deleteIfExists(path);forgetPreferences();
|
||||
check(VanillaLight.bloom()&&VanillaLight.bloomIntensity()==20&&VanillaLight.pbr()&&VanillaLight.ssgi(),"Fresh defaults: bloom20, PBR and SSGI ON");
|
||||
check(VanillaLight.pbrIntensity()==80&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 80 and SSR 20 defaults, SSGI strength retained");
|
||||
check(VanillaLight.pbrIntensity()==50&&VanillaLight.ssrIntensity()==20&&VanillaLight.ssgiIntensity()==35,"PBR 50 and SSR 20 defaults, SSGI strength retained");
|
||||
Files.writeString(path,"{\"pbr\":false,\"ssgi\":false,\"bloomIntensity\":73}");forgetPreferences();
|
||||
check(!VanillaLight.pbr()&&!VanillaLight.ssgi()&&VanillaLight.bloomIntensity()==73,"Explicit personal toggles and intensity survive changed defaults");
|
||||
Files.writeString(path,"{\"bloom\":false}");forgetPreferences();
|
||||
|
||||
@@ -43,6 +43,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
fr.koka.sanctuary.community.CommunityNetwork.register();
|
||||
fr.koka.sanctuary.building.ColoredBricks.register();
|
||||
fr.koka.sanctuary.gems.Gems.register();
|
||||
fr.koka.sanctuary.sky.VortexClouds.register();
|
||||
|
||||
@@ -19,7 +19,7 @@ public final class AtlasService {
|
||||
private static final Map<MinecraftServer,Map<UUID,State>> STATES=new WeakHashMap<>();
|
||||
private static final int[][] OFFSETS=offsets();
|
||||
private static final class State {
|
||||
AtlasStore store;boolean failed;int cursor,lastRequest=-100,lastSave,saveLayer;Job job;
|
||||
AtlasStore store;boolean failed;int cursor,lastRequest=-100,lastSave,saveLayer;long lastRequestNanos;Job job;
|
||||
final Map<Integer,AtlasStore> layers=new LinkedHashMap<>();
|
||||
}
|
||||
private record Job(AtlasPayloads.Request request,ArrayDeque<AtlasStore.Key> remaining) { }
|
||||
@@ -75,8 +75,9 @@ public final class AtlasService {
|
||||
if (request.token()==0) {state.job=null;return;}
|
||||
int tick=player.level().getServer().getTickCount();
|
||||
state.job=null;
|
||||
if (tick-state.lastRequest<10) {reply(player,request,true,true,"retry",List.of());return;}
|
||||
state.lastRequest=tick;
|
||||
long now=System.nanoTime();
|
||||
if (tick-state.lastRequest<10 && now-state.lastRequestNanos<500_000_000L) {reply(player,request,true,true,"retry",List.of());return;}
|
||||
state.lastRequest=tick;state.lastRequestNanos=now;
|
||||
if (state.failed) {reply(player,request,true,true,"storage_invalid",List.of());return;}
|
||||
if (!canRead(player,request.operator())) {
|
||||
reply(player,request,true,true,player.level().dimension()!=Level.OVERWORLD ? "dimension" : request.operator() ? "denied" : "locked",List.of());return;
|
||||
@@ -123,24 +124,35 @@ public final class AtlasService {
|
||||
catch (Exception error) {state.failed=true;state.job=null;SanctuaryMod.LOGGER.error("Could not save atlas layer (file preserved)",error);}
|
||||
state.lastSave=tick;saved=true;
|
||||
}
|
||||
var job=state.job;
|
||||
if (job==null) continue;
|
||||
if (!canRead(player,job.request.operator())) {
|
||||
reply(player,job.request,true,true,"denied",List.of());state.job=null;continue;
|
||||
}
|
||||
var tiles=new ArrayList<AtlasStore.Tile>();
|
||||
int budget=job.request.operator() ? 4 : AtlasPayloads.BATCH;
|
||||
while (budget-->0 && !job.remaining.isEmpty()) {
|
||||
var key=job.remaining.removeFirst();
|
||||
var tile=job.request.operator() ? AtlasSampler.sample(player.level(),key.x(),key.z(),job.request.altitude())
|
||||
: state.layers.get(job.request.altitude()).get(key.x(),key.z());
|
||||
if (tile!=null) tiles.add(tile);
|
||||
}
|
||||
boolean done=job.remaining.isEmpty();
|
||||
reply(player,job.request,false,done,state.layers.get(job.request.altitude()).full() ? "full" : "ready",tiles);
|
||||
if (done) state.job=null;
|
||||
stream(player,state);
|
||||
}
|
||||
}
|
||||
/** The paused integrated server may send stored personal tiles, never sample or advance the world. */
|
||||
public static void streamPaused(MinecraftServer server) {
|
||||
var states=STATES.get(server);if(states==null)return;
|
||||
for(var player:server.getPlayerList().getPlayers()) {
|
||||
var state=states.get(player.getUUID());
|
||||
if(state!=null && !state.failed && state.job!=null && !state.job.request.operator())stream(player,state);
|
||||
}
|
||||
}
|
||||
private static void stream(ServerPlayer player,State state) {
|
||||
var job=state.job;
|
||||
if (job==null) return;
|
||||
if (!canRead(player,job.request.operator())) {
|
||||
reply(player,job.request,true,true,"denied",List.of());state.job=null;return;
|
||||
}
|
||||
var tiles=new ArrayList<AtlasStore.Tile>();
|
||||
int budget=job.request.operator() ? 4 : AtlasPayloads.BATCH;
|
||||
while (budget-->0 && !job.remaining.isEmpty()) {
|
||||
var key=job.remaining.removeFirst();
|
||||
var tile=job.request.operator() ? AtlasSampler.sample(player.level(),key.x(),key.z(),job.request.altitude())
|
||||
: state.layers.get(job.request.altitude()).get(key.x(),key.z());
|
||||
if (tile!=null) tiles.add(tile);
|
||||
}
|
||||
boolean done=job.remaining.isEmpty();
|
||||
reply(player,job.request,false,done,state.layers.get(job.request.altitude()).full() ? "full" : "ready",tiles);
|
||||
if (done) state.job=null;
|
||||
}
|
||||
private static void reply(ServerPlayer player,AtlasPayloads.Request request,boolean reset,boolean done,String status,List<AtlasStore.Tile> tiles) {
|
||||
if (ServerPlayNetworking.canSend(player,AtlasPayloads.Response.TYPE)) ServerPlayNetworking.send(player,
|
||||
new AtlasPayloads.Response(request.token(),reset,done,canOperate(player),request.operator(),status,tiles));
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class AtlasClient {
|
||||
}));
|
||||
ClientPlayNetworking.registerGlobalReceiver(AtlasPayloads.Response.TYPE,(p,c) -> c.client().execute(() -> {
|
||||
if (c.client().gui.screen() instanceof SanctuaryAtlasScreen screen) screen.accept(p);
|
||||
else if (c.client().gui.screen() instanceof PauseAtlasAccess pause && pause.sanctuary$pauseMap()!=null) pause.sanctuary$pauseMap().accept(p);
|
||||
}));
|
||||
}
|
||||
public static int nextToken() {sequence=sequence==Integer.MAX_VALUE ? 1 : sequence+1;return sequence;}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
|
||||
/** One compact, wrapped message; ordinary text never becomes a clickable command. */
|
||||
final class CommunityBubble extends AbstractWidget {
|
||||
private final List<FormattedCharSequence> lines;
|
||||
private final boolean own;
|
||||
CommunityBubble(int width, String body, boolean own) {
|
||||
super(0,0,width,20,Component.literal(body));
|
||||
this.own=own;
|
||||
lines=Minecraft.getInstance().font.split(getMessage(),Math.max(20,width-16));
|
||||
height=12+lines.size()*11;
|
||||
active=false;
|
||||
}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
int x=getX(),y=getY();
|
||||
g.fill(x,y,x+width,y+height,own?0xC42B3732:0xB51B2028);
|
||||
g.fill(x,y,x+2,y+height,own?0xFF819D87:0xFF5E6879);
|
||||
for(int i=0;i<lines.size();i++)g.text(Minecraft.getInstance().font,lines.get(i),x+8,y+6+i*11,0xFFF0F0F0,false);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output) { defaultButtonNarrationText(output); }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.community.CommunityNetwork;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
public final class CommunityClient {
|
||||
public static JsonObject overview = new JsonObject();
|
||||
public static boolean stale = true;
|
||||
public static String overviewError = "";
|
||||
private static int sequence, pending, ticks;
|
||||
private static Screen owner;
|
||||
private static Consumer<JsonObject> callback;
|
||||
private static boolean overviewRequest;
|
||||
static final Map<String,Draft> DRAFTS = new HashMap<>();
|
||||
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info", photo = ""; }
|
||||
private CommunityClient() { }
|
||||
public static net.minecraft.network.chat.MutableComponent text(String key, Object... args) { return Component.translatable("sanctuary.community." + key,args); }
|
||||
public static boolean available() { return ClientPlayNetworking.canSend(CommunityNetwork.Request.TYPE); }
|
||||
public static boolean pending() { return pending != 0; }
|
||||
public static void register() {
|
||||
ClientPlayNetworking.registerGlobalReceiver(CommunityNetwork.Response.TYPE,(r,c) -> c.client().execute(() -> {
|
||||
if (r.sequence() != pending) return;
|
||||
var result = JsonParser.parseString(r.json()).getAsJsonObject(); finish(result);
|
||||
}));
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((h,c) -> { pending = 0; owner = null; callback = null; overview = new JsonObject(); stale = true; overviewError = ""; DRAFTS.clear(); });
|
||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
|
||||
});
|
||||
}
|
||||
private static void finish(JsonObject result) {
|
||||
pending = 0;
|
||||
if (overviewRequest) {
|
||||
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
|
||||
else { stale = true; overviewError = str(result,"error"); }
|
||||
}
|
||||
var receiver = callback; var from = owner; callback = null; owner = null;
|
||||
if (receiver != null && Minecraft.getInstance().gui.screen() == from) receiver.accept(result);
|
||||
}
|
||||
public static boolean request(Screen screen,String action,String id,String parent,String kind,String category,
|
||||
String title,String body,int revision,int page,Consumer<JsonObject> receiver) {
|
||||
if (!available() || pending != 0) return false;
|
||||
owner = screen; callback = receiver; overviewRequest = action.equals("overview"); ticks = 0;
|
||||
pending = ++sequence;
|
||||
ClientPlayNetworking.send(new CommunityNetwork.Request(pending,action,id,parent,kind,category,title,body,revision,page));
|
||||
return true;
|
||||
}
|
||||
public static void overview(Screen screen) { request(screen,"overview","","","article","info","","",0,0,r -> { }); }
|
||||
public static void open(Screen parent,String kind,String id,boolean compose) {
|
||||
Minecraft.getInstance().gui.setScreen(new CommunityScreen(parent,kind,id,compose));
|
||||
}
|
||||
static String str(JsonObject value,String key) { return value.has(key) ? value.get(key).getAsString() : ""; }
|
||||
static int number(JsonObject value,String key) { return value.has(key) ? value.get(key).getAsInt() : 0; }
|
||||
static boolean flag(JsonObject value,String key) { return value.has(key) && value.get(key).getAsBoolean(); }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** Three spatial regions, with independent community feeds and native exit/options actions. */
|
||||
public final class CommunityPausePanels implements AutoCloseable {
|
||||
private final Screen screen;
|
||||
private final PauseCommunityFeed articles, notices;
|
||||
private final StringWidget date, clock;
|
||||
private final PauseTextButton bulletin;
|
||||
private final PauseAtlasWidget map;
|
||||
private int ticks;
|
||||
private boolean closed;
|
||||
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
|
||||
this.screen=screen;
|
||||
var font=Minecraft.getInstance().font;
|
||||
int margin=8,gap=10,top=40,bottom=screen.height-38;
|
||||
int left=Math.min(132,Math.max(76,screen.width/5)),right=Math.min(244,Math.max(104,screen.width*31/100));
|
||||
int centerX=margin+left+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
|
||||
date=new StringWidget(margin,8,left,20,Component.empty(),font);
|
||||
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
|
||||
add.accept(date);add.accept(clock);
|
||||
int headerSide=Math.max(84,screen.width/5);
|
||||
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->{
|
||||
var list=items("bulletins");open(screen,"bulletin",list.isEmpty()?"":str(list.get(0).getAsJsonObject(),"id"),false);
|
||||
});
|
||||
add.accept(bulletin);
|
||||
String[] sections={"blocks","history","progress","identity"};
|
||||
int optionsTop=screen.height-76;
|
||||
int navHeight=Math.min(20,Math.max(16,(optionsTop-top-28)/6));
|
||||
int step=navHeight+4;
|
||||
for(int i=0;i<sections.length;i++) {
|
||||
String target=sections[i];
|
||||
add.accept(Button.builder(fr.koka.sanctuary.progression.ProgressionService.label(target),b->ProgressionClient.open(screen,target))
|
||||
.bounds(margin,top+i*step,left,navHeight).build());
|
||||
}
|
||||
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+4*step,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
|
||||
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
|
||||
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
|
||||
int feedHeight=(bottom-top-gap)/2;
|
||||
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
|
||||
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
|
||||
place(nativeButtons,add,"menu.options",margin,optionsTop,left);
|
||||
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+24,left);
|
||||
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
|
||||
button.setRectangle(left,20,margin,optionsTop+48);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
|
||||
}
|
||||
updateHeader();
|
||||
}
|
||||
public static String key(Button button) { return button.getMessage().getContents() instanceof TranslatableContents t?t.getKey():""; }
|
||||
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width) {
|
||||
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,20,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
|
||||
}
|
||||
private static JsonArray items(String key) { return overview.has(key)?overview.getAsJsonArray(key):new JsonArray(); }
|
||||
private void updateHeader() {
|
||||
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
|
||||
var now=ZonedDateTime.now();
|
||||
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM",locale).format(now)));
|
||||
clock.setMessage(Component.literal(DateTimeFormatter.ofPattern("HH:mm",locale).format(now)));
|
||||
clock.setX(screen.width-8-Minecraft.getInstance().font.width(clock.getMessage()));clock.setWidth(Minecraft.getInstance().font.width(clock.getMessage()));
|
||||
var list=items("bulletins");
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
|
||||
if(!overviewError.isEmpty())message=text(overviewError);
|
||||
bulletin.setMessage(message);
|
||||
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
|
||||
bulletin.active=available();
|
||||
}
|
||||
public void tick() {
|
||||
if(closed)return;
|
||||
map.tick(); updateHeader();
|
||||
if(++ticks%6!=0 || pending() || !available())return;
|
||||
if(ticks==6 || ticks%240==0){overview(screen);if(ticks>6){articles.refresh();notices.refresh();}}
|
||||
else if(!articles.load())notices.load();
|
||||
}
|
||||
public PauseAtlasWidget map() { return map; }
|
||||
@Override public void close() {closed=true;articles.close();notices.close();map.close();}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractButton;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** A locally owned texture; never installed under a user-controlled resource identifier. */
|
||||
final class CommunityPhotoWidget extends AbstractButton implements AutoCloseable {
|
||||
private final Identifier texture=Identifier.fromNamespaceAndPath("sanctuary","community/"+UUID.randomUUID());
|
||||
private final int imageWidth,imageHeight;
|
||||
private final Runnable action;
|
||||
private final boolean caption;
|
||||
private boolean closed;
|
||||
CommunityPhotoWidget(int width,int height,BufferedImage image,Component label,boolean caption,Runnable action) {
|
||||
super(0,0,width,height,label);this.action=action;this.caption=caption;
|
||||
imageWidth=image.getWidth();imageHeight=image.getHeight();
|
||||
var nativeImage=new NativeImage(imageWidth,imageHeight,false);
|
||||
for(int y=0;y<imageHeight;y++)for(int x=0;x<imageWidth;x++)nativeImage.setPixel(x,y,0xff000000|image.getRGB(x,y));
|
||||
Minecraft.getInstance().getTextureManager().register(texture,new DynamicTexture(()->"Gazette photo",nativeImage));
|
||||
active=action!=null;
|
||||
}
|
||||
@Override public void onPress(InputWithModifiers input) {if(action!=null)action.run();}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
if(closed)return;
|
||||
int x=getX(),y=getY(),available=height-(caption?18:0);
|
||||
g.fill(x,y,x+width,y+height,isHoveredOrFocused()?0xff536957:0xff1b2028);
|
||||
double scale=Math.min((double)(width-4)/imageWidth,(double)(available-4)/imageHeight);
|
||||
int w=(int)(imageWidth*scale),h=(int)(imageHeight*scale);
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,texture,x+(width-w)/2,y+(available-h)/2,0,0,w,h,imageWidth,imageHeight,imageWidth,imageHeight);
|
||||
if(caption)g.text(Minecraft.getInstance().font,Minecraft.getInstance().font.plainSubstrByWidth(getMessage().getString(),width-10),x+5,y+height-13,0xffeeeeee,false);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
|
||||
@Override public void close() {if(!closed){closed=true;Minecraft.getInstance().getTextureManager().release(texture);}}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** Native community pages: user content is always a literal Minecraft component. */
|
||||
public final class CommunityScreen extends BlocodexFrame {
|
||||
private final String kind;
|
||||
private String id, editingId = "", message = "", expandedId = "";
|
||||
private int page, revision;
|
||||
private boolean composing, needsLoad = true, moderator;
|
||||
private JsonObject data = new JsonObject();
|
||||
private Draft draft;
|
||||
private int ticks;
|
||||
private final java.util.List<CommunityPhotoWidget> photos=new java.util.ArrayList<>();
|
||||
private boolean publishing;
|
||||
private int photoPart, sendAfter;
|
||||
private String outgoingPhoto="";
|
||||
private Runnable commit;
|
||||
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.clear();}
|
||||
@Override public void removed() {closePhotos();publishing=false;commit=null;}
|
||||
|
||||
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
|
||||
super(parent); this.kind = kind; this.id = id; this.composing = compose;
|
||||
moderator = flag(overview,"moderator");
|
||||
if (compose) useDraft("new:" + kind);
|
||||
}
|
||||
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
|
||||
@Override protected void init() {
|
||||
closePhotos();initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
|
||||
var layout = LinearLayout.vertical().spacing(8);
|
||||
if (!message.isEmpty()) line(layout,w,text(message));
|
||||
if (!available()) line(layout,w,text("unavailable"));
|
||||
if (composing) compose(layout,w);
|
||||
else if (data.has("thread")) detail(layout,w,data.getAsJsonObject("thread"));
|
||||
else listing(layout,w);
|
||||
var scroll = new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setPosition(contentX+8,contentTop); scroll.arrangeElements(); scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void listing(LinearLayout layout,int w) {
|
||||
var tabs = layout.addChild(LinearLayout.horizontal().spacing(4));
|
||||
for (String target : new String[]{"article","notice","bulletin"}) tabs.addChild(Button.builder(text(target), b -> open(parent,target,"",false)).width((w-8)/3).build());
|
||||
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(4));
|
||||
if (!kind.equals("bulletin") || moderator) button(toolbar,(w-8)/3,"publish",() -> { composing = true; useDraft("new:"+kind); rebuildWidgets(); });
|
||||
button(toolbar,(w-8)/3,"refresh",() -> needsLoad = true);
|
||||
button(toolbar,(w-8)/3,"link",() -> minecraft.gui.setScreen(new LinkScreen(this)));
|
||||
if (!data.has("page")) { line(layout,w,text("loading")); return; }
|
||||
var listing = data.getAsJsonObject("page");
|
||||
if (listing.getAsJsonArray("entries").isEmpty()) line(layout,w,text("empty"));
|
||||
for (var item : listing.getAsJsonArray("entries")) {
|
||||
var entry = item.getAsJsonObject();
|
||||
layout.addChild(Button.builder(Component.literal((flag(entry,"pinned") ? "★ " : "") + str(entry,"title")), b -> { id=str(entry,"id"); page=0; needsLoad=true; }).width(w).build());
|
||||
author(layout,w,entry,false);
|
||||
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
|
||||
}
|
||||
pages(layout,w,listing);
|
||||
}
|
||||
private void detail(LinearLayout layout,int w,JsonObject thread) {
|
||||
var entry = thread.getAsJsonObject("entry");
|
||||
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(5));
|
||||
button(toolbar,90,"back",() -> { id=""; page=0; data=new JsonObject(); needsLoad=true; rebuildWidgets(); });
|
||||
button(toolbar,90,"refresh",() -> needsLoad=true);
|
||||
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));
|
||||
photo(layout,w,str(entry,"photo"));
|
||||
message(layout,w,entry);
|
||||
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
|
||||
if (!kind.equals("bulletin")) {
|
||||
line(layout,w,text("replies"));
|
||||
var replies = thread.getAsJsonObject("replies");
|
||||
for (var item : replies.getAsJsonArray("entries")) message(layout,w,item.getAsJsonObject());
|
||||
pages(layout,w,replies);
|
||||
if (str(entry,"status").equals("open")) {
|
||||
useDraft("reply:"+id);
|
||||
var replyDraft=draft;
|
||||
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
|
||||
box.setCharacterLimit(1000); box.setValue(replyDraft.body); box.setValueListener(value -> replyDraft.body=value);
|
||||
button(layout,100,"reply",() -> request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result -> {
|
||||
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
|
||||
DRAFTS.values().removeIf(value -> value==replyDraft); message="saved"; needsLoad=true; stale=true;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
|
||||
private void author(LinearLayout layout,int w,JsonObject entry,boolean actions) {
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
|
||||
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
|
||||
var labels=row.addChild(LinearLayout.vertical().spacing(3));
|
||||
labels.addChild(new StringWidget(w-58,11,Component.literal(str(entry,"authorName")),font));
|
||||
labels.addChild(new StringWidget(w-58,10,Component.literal(date(entry)).withStyle(net.minecraft.ChatFormatting.GRAY),font));
|
||||
if(actions && (own(entry)||moderator)) row.addChild(Button.builder(text("actions"),b -> {
|
||||
expandedId=expandedId.equals(str(entry,"id"))?"":str(entry,"id"); rebuildWidgets();
|
||||
}).size(20,18).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
|
||||
}
|
||||
private void message(LinearLayout layout,int w,JsonObject entry) {
|
||||
author(layout,w,entry,true);
|
||||
layout.addChild(new CommunityBubble(w,str(entry,"body"),own(entry)));
|
||||
if(expandedId.equals(str(entry,"id"))) controls(layout,w,entry);
|
||||
}
|
||||
private void controls(LinearLayout layout,int w,JsonObject entry) {
|
||||
boolean own = own(entry);
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
|
||||
if (own) button(row,size,"edit",() -> {
|
||||
editingId=str(entry,"id"); revision=number(entry,"revision"); composing=true; useDraft("edit:"+editingId);
|
||||
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); draft.photo=str(entry,"photo"); }
|
||||
rebuildWidgets();
|
||||
});
|
||||
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
|
||||
confirmed -> { minecraft.gui.setScreen(this); if (confirmed) mutate("hide",entry); },text("hide"),text("hide_confirm"))));
|
||||
if ((own || moderator) && str(entry,"kind").equals("notice")) {
|
||||
String action=str(entry,"status").equals("closed")?"reopen":"close"; button(row,size,action,() -> mutate(action,entry));
|
||||
}
|
||||
if (moderator && !str(entry,"kind").equals("reply")) button(row,size,flag(entry,"pinned")?"unpin":"pin",() -> mutate("pin",entry));
|
||||
}
|
||||
private void compose(LinearLayout layout,int w) {
|
||||
boolean reply = !id.isEmpty() && (editingId.isEmpty() || !editingId.equals(id));
|
||||
if (!reply) {
|
||||
line(layout,w,text("title_label")); var titleBox=layout.addChild(new EditBox(font,w,20,text("title_label")));
|
||||
titleBox.setMaxLength(120); titleBox.setValue(draft.title); titleBox.setResponder(value -> draft.title=value);
|
||||
if (kind.equals("notice") && editingId.isEmpty()) button(layout,w,"category."+draft.category,() -> {
|
||||
String[] categories={"info","work","need","event"}; int i=java.util.Arrays.asList(categories).indexOf(draft.category);
|
||||
draft.category=categories[(i+1)%categories.length]; rebuildWidgets();
|
||||
});
|
||||
}
|
||||
if(!reply && kind.equals("article")) {
|
||||
photo(layout,w,draft.photo);
|
||||
button(layout,w,draft.photo.isEmpty()?"photo_choose":"photo_change",()->minecraft.gui.setScreen(new CommunityScreenshotsScreen(this,value->draft.photo=value)));
|
||||
if(draft.photo.isEmpty())line(layout,w,text("photo_required"));
|
||||
}
|
||||
line(layout,w,text("body_label"));
|
||||
var bodyBox=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("body_hint")).build(font,w,Math.max(60,Math.min(150,height/3)),text("body_label")));
|
||||
bodyBox.setCharacterLimit(reply?1000:4000); bodyBox.setValue(draft.body); bodyBox.setValueListener(value -> draft.body=value);
|
||||
var publish=layout.addChild(Button.builder(text(publishing?"photo_uploading":editingId.isEmpty()?"publish":"save"),b -> {
|
||||
if(pending() || publishing)return;
|
||||
String action=editingId.isEmpty()?"create":"edit";
|
||||
commit=()->request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
|
||||
reply?"":draft.title,draft.body,revision,0,result -> {
|
||||
publishing=false;commit=null;
|
||||
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
|
||||
String saved=str(result.getAsJsonObject("result"),"id");
|
||||
DRAFTS.values().removeIf(value -> value==draft); composing=false; editingId="";
|
||||
if (!reply) id=saved; page=0; needsLoad=true; message="saved"; stale=true; rebuildWidgets();
|
||||
});
|
||||
if(!reply && kind.equals("article")) {outgoingPhoto=draft.photo;photoPart=0;publishing=true;sendAfter=ticks+4;rebuildWidgets();}
|
||||
else {var actionToRun=commit;commit=null;actionToRun.run();}
|
||||
}).width(150).build());
|
||||
publish.active=!publishing && (reply || !kind.equals("article") || !draft.photo.isEmpty());
|
||||
button(layout,Math.min(w,220),"back_draft",() -> { composing=false; editingId=""; needsLoad=true; rebuildWidgets(); });
|
||||
}
|
||||
private void photo(LinearLayout layout,int w,String value) {
|
||||
if(value.isEmpty())return;
|
||||
try {
|
||||
var image=fr.koka.sanctuary.community.CommunityPhoto.decode(java.util.Base64.getDecoder().decode(value),960,540);
|
||||
var widget=new CommunityPhotoWidget(w,Math.max(24,Math.min(200,w*image.getHeight()/image.getWidth())),image,text("photo"),false,null);
|
||||
photos.add(widget);layout.addChild(widget);
|
||||
} catch(Exception e) {line(layout,w,text("photo_invalid"));}
|
||||
}
|
||||
private void mutate(String action,JsonObject entry) {
|
||||
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
|
||||
message=result.has("error")?str(result,"error"):"saved";
|
||||
if (!result.has("error") && action.equals("hide") && str(entry,"id").equals(id)) { id=""; page=0; }
|
||||
stale=true; needsLoad=true; rebuildWidgets();
|
||||
});
|
||||
}
|
||||
private void pages(LinearLayout layout,int w,JsonObject listing) {
|
||||
if(page==0 && !flag(listing,"more"))return;
|
||||
w=Math.min(w,280);
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(4));
|
||||
row.addChild(Button.builder(text("previous"),b -> { page=Math.max(0,page-1); needsLoad=true; }).width((w-8)/3).build()).active=page>0;
|
||||
row.addChild(new StringWidget((w-8)/3,20,text("page",page+1),font));
|
||||
row.addChild(Button.builder(text("next"),b -> { page++; needsLoad=true; }).width((w-8)/3).build()).active=flag(listing,"more");
|
||||
}
|
||||
private void line(LinearLayout layout,int w,Component value) { layout.addChild(new MultiLineTextWidget(value,font).setMaxWidth(w)); }
|
||||
private void button(LinearLayout layout,int w,String key,Runnable action) {
|
||||
layout.addChild(Button.builder(text(key),b -> { if (!pending() && !publishing) action.run(); }).width(w).build());
|
||||
}
|
||||
private static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
|
||||
@Override public void tick() {
|
||||
super.tick(); ticks++;
|
||||
if(publishing && !pending() && ticks>=sendAfter) {
|
||||
int parts=(outgoingPhoto.length()+7999)/8000;
|
||||
if(photoPart<parts) {
|
||||
int part=photoPart;
|
||||
request(this,"photo",editingId.isEmpty()?draft.operation:editingId,"","article","info","",outgoingPhoto.substring(part*8000,Math.min(outgoingPhoto.length(),(part+1)*8000)),parts,part,result->{
|
||||
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
|
||||
else {photoPart++;sendAfter=ticks+4;}
|
||||
});
|
||||
} else if(commit!=null) {var actionToRun=commit;commit=null;actionToRun.run();}
|
||||
}
|
||||
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
|
||||
if (result.has("error")) message=str(result,"error");
|
||||
else { data=result; moderator=flag(result,"moderator"); }
|
||||
rebuildWidgets();
|
||||
})) needsLoad=false;
|
||||
}
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
private static final class LinkScreen extends BlocodexFrame {
|
||||
String code="", message="link_help";
|
||||
LinkScreen(Screen parent) { super(parent); }
|
||||
@Override protected void init() {
|
||||
initFrame("history",text("link")); int w=contentWidth-28; var layout=LinearLayout.vertical().spacing(8);
|
||||
layout.addChild(new MultiLineTextWidget(text(message),font).setMaxWidth(w));
|
||||
var box=layout.addChild(new EditBox(font,w,20,text("code"))); box.setMaxLength(32); box.setValue(code); box.setResponder(value -> code=value);
|
||||
layout.addChild(Button.builder(text("link"),b -> request(this,"link","","","article","info","",code,0,0,r -> { message=r.has("error")?str(r,"error"):"linked"; rebuildWidgets(); })).width(w).build());
|
||||
layout.setPosition(contentX+8,contentTop); layout.arrangeElements(); layout.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityPhoto;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
/** Read-only screenshot library, paged and scrollable; no disk or image decoding on the render thread. */
|
||||
public final class CommunityScreenshotsScreen extends BlocodexFrame {
|
||||
private static final int PAGE_SIZE=24;
|
||||
private static final ExecutorService LOADER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary screenshots");t.setDaemon(true);return t;});
|
||||
private record Shot(String name,String photo,BufferedImage thumbnail) { }
|
||||
private record Library(List<Path> paths,List<Shot> shots,String error) { }
|
||||
private final Consumer<String> assign;
|
||||
private final List<CommunityPhotoWidget> textures=new ArrayList<>();
|
||||
private List<Path> paths=List.of();
|
||||
private List<Shot> shots=List.of();
|
||||
private CompletableFuture<Library> loading;
|
||||
private int page;
|
||||
private boolean loaded;
|
||||
private String error="";
|
||||
private Shot selected;
|
||||
public CommunityScreenshotsScreen(Screen parent,Consumer<String> assign) {super(parent);this.assign=assign;}
|
||||
private void load(boolean refresh) {
|
||||
loaded=true;
|
||||
Path folder=minecraft.gameDirectory.toPath().resolve("screenshots");
|
||||
List<Path> known=paths;int offset=page*PAGE_SIZE;
|
||||
loading=CompletableFuture.supplyAsync(()->{
|
||||
try {
|
||||
List<Path> all=known;
|
||||
if(refresh) {
|
||||
if(!Files.isDirectory(folder))return new Library(List.of(),List.of(),"");
|
||||
try(var files=Files.list(folder)) {
|
||||
all=files.filter(p->p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")&&Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))
|
||||
.sorted(Comparator.comparing((Path p)->p.getFileName().toString()).reversed()).limit(4096).toList();
|
||||
}
|
||||
}
|
||||
var found=new ArrayList<Shot>();
|
||||
for(Path file:all.stream().skip(offset).limit(PAGE_SIZE).toList()) {
|
||||
try {
|
||||
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS) || Files.size(file)>32*1024*1024)throw new java.io.IOException("Screenshot size");
|
||||
byte[] bytes;
|
||||
try(var in=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)) {bytes=in.readNBytes(32*1024*1024+1);}
|
||||
if(bytes.length>32*1024*1024)throw new java.io.IOException("Screenshot size");
|
||||
var source=CommunityPhoto.decode(bytes,8192,8192);
|
||||
String photo=CommunityPhoto.encode(source,CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
|
||||
String thumb=CommunityPhoto.encode(source,240,135);
|
||||
found.add(new Shot(file.getFileName().toString(),photo,CommunityPhoto.decode(Base64.getDecoder().decode(thumb),240,135)));
|
||||
} catch(Exception e) {found.add(new Shot(file.getFileName().toString(),"",null));}
|
||||
}
|
||||
return new Library(all,found,"");
|
||||
} catch(Exception e) {return new Library(List.of(),List.of(),"screenshots_error");}
|
||||
},LOADER);
|
||||
}
|
||||
@Override protected void init() {
|
||||
closeTextures();initFrame("history",text("screenshots"));
|
||||
if(!loaded)load(true);
|
||||
if(selected!=null) {preview();return;}
|
||||
int w=contentWidth-24;
|
||||
var controls=LinearLayout.horizontal().spacing(4);
|
||||
controls.addChild(Button.builder(text("previous"),b->{page--;load(false);rebuildWidgets();}).width(72).build()).active=page>0 && loading==null;
|
||||
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
|
||||
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
|
||||
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
|
||||
controls.setPosition(contentX+8,contentBottom-20);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
|
||||
var layout=LinearLayout.vertical().spacing(8);
|
||||
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
|
||||
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
|
||||
else {
|
||||
int columns=w>=450?3:2,cell=(w-(columns-1)*8)/columns;
|
||||
for(int i=0;i<shots.size();i+=columns) {
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
|
||||
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
|
||||
Shot shot=shots.get(j);
|
||||
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
|
||||
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
|
||||
}
|
||||
}
|
||||
}
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void preview() {
|
||||
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
|
||||
try {
|
||||
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
|
||||
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
|
||||
textures.add(widget);widget.setPosition(contentX+8,contentTop);addRenderableWidget(widget);
|
||||
} catch(Exception e) {error="photo_invalid";selected=null;return;}
|
||||
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(contentX+8,contentBottom-24,(w-6)/2,20).build());
|
||||
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(contentX+8+(w+6)/2,contentBottom-24,(w-6)/2,20).build());
|
||||
}
|
||||
@Override public void tick() {
|
||||
if(loading!=null && loading.isDone()) {
|
||||
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
|
||||
}
|
||||
}
|
||||
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
|
||||
@Override public void removed() {closeTextures();}
|
||||
@Override public boolean isPauseScreen() {return false;}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
/** Routes network responses to the current pause screen, never a static cross-server cache. */
|
||||
public interface PauseAtlasAccess {
|
||||
PauseAtlasWidget sanctuary$pauseMap();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.atlas.AtlasPayloads;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractButton;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.material.MapColor;
|
||||
|
||||
/** Personal surveyed terrain only. A token and disposable texture belong to this pause instance. */
|
||||
public final class PauseAtlasWidget extends AbstractButton implements AutoCloseable {
|
||||
private static final int SIZE=AtlasPayloads.PIXELS;
|
||||
private static final Identifier TEXTURE=SanctuaryMod.id("pause_atlas_viewport");
|
||||
private final Screen owner;
|
||||
private final Minecraft client=Minecraft.getInstance();
|
||||
private DynamicTexture texture;
|
||||
private int token,originX,originZ,refresh;
|
||||
private double centerX,centerZ;
|
||||
private String dimension="",status="loading";
|
||||
private boolean dirty,readable;
|
||||
private int pixels;
|
||||
PauseAtlasWidget(Screen owner,int x,int y,int width,int height) {
|
||||
super(x,y,width,height,Component.translatable("sanctuary.pause.map"));this.owner=owner;
|
||||
setTooltip(Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
|
||||
texture=new DynamicTexture("Sanctuary pause map",SIZE,SIZE,false);
|
||||
client.getTextureManager().register(TEXTURE,texture);reset();request();
|
||||
}
|
||||
private void reset() {texture.getPixels().fillRect(0,0,SIZE,SIZE,0);dirty=true;readable=false;pixels=0;status="loading";}
|
||||
private void request() {
|
||||
if(client.player==null || client.level==null)return;
|
||||
centerX=client.player.getX();centerZ=client.player.getZ();
|
||||
int cx=Math.floorDiv((int)Math.floor(centerX),16),cz=Math.floorDiv((int)Math.floor(centerZ),16);
|
||||
int nx=(cx-AtlasPayloads.SIDE/2)*16,nz=(cz-AtlasPayloads.SIDE/2)*16;
|
||||
String next=client.level.dimension().identifier().toString();
|
||||
if(nx!=originX || nz!=originZ || !next.equals(dimension))reset();
|
||||
originX=nx;originZ=nz;dimension=next;token=AtlasClient.nextToken();refresh=1600;
|
||||
AtlasClient.send(new AtlasPayloads.Request(token,cx,cz,false));
|
||||
}
|
||||
public void accept(AtlasPayloads.Response response) {
|
||||
if(texture==null || response.token()!=token || client.level==null || !dimension.equals(client.level.dimension().identifier().toString()))return;
|
||||
if(response.status().equals("retry")){refresh=12;return;}
|
||||
status=response.status();readable=status.equals("ready") || status.equals("full");
|
||||
if(!readable) {reset();status=response.status();refresh=100;return;}
|
||||
for(var tile:response.tiles()) {
|
||||
int x=tile.x()*16-originX,z=tile.z()*16-originZ;if(x<0 || z<0 || x+16>SIZE || z+16>SIZE)continue;
|
||||
byte[] colors=tile.colors();
|
||||
for(int dz=0;dz<16;dz++)for(int dx=0;dx<16;dx++) {
|
||||
int packed=Byte.toUnsignedInt(colors[dz*16+dx]);
|
||||
int color=packed<4?0:MapColor.getColorFromPackedId(packed);
|
||||
int before=texture.getPixels().getPixel(x+dx,z+dz);
|
||||
if(before==0 && color!=0)pixels++;else if(before!=0 && color==0)pixels--;
|
||||
texture.getPixels().setPixel(x+dx,z+dz,color);
|
||||
}
|
||||
dirty=true;
|
||||
}
|
||||
if(response.done())refresh=100;
|
||||
}
|
||||
void tick() {
|
||||
if(texture==null)return;
|
||||
if(client.level!=null && !dimension.equals(client.level.dimension().identifier().toString())) {reset();request();}
|
||||
if(--refresh<=0)request();
|
||||
}
|
||||
public int knownPixels() {return pixels;}
|
||||
@Override public void onPress(InputWithModifiers input) {ProgressionClient.open(owner,"history");}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
if(texture==null)return;
|
||||
if(dirty){texture.upload();dirty=false;}
|
||||
int x=getX(),y=getY();var font=client.font;
|
||||
g.fill(x,y,getRight(),getBottom(),0x90151B19);
|
||||
g.enableScissor(x,y,getRight(),getBottom());
|
||||
if(readable) {
|
||||
double zoom=Math.max(.75,Math.max(width,height)/992.);
|
||||
int left=x+width/2+(int)((originX-centerX)*zoom),top=y+height/2+(int)((originZ-centerZ)*zoom);
|
||||
g.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,left,top,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
|
||||
int px=x+width/2,pz=y+height/2;
|
||||
if(client.player!=null) {
|
||||
px+=(int)((client.player.getX()-centerX)*zoom);pz+=(int)((client.player.getZ()-centerZ)*zoom);
|
||||
double yaw=Math.toRadians(client.player.getYRot());
|
||||
for(int i=2;i<9;i++){int ax=px-(int)Math.round(Math.sin(yaw)*i),az=pz+(int)Math.round(Math.cos(yaw)*i);g.fill(ax-1,az-1,ax+2,az+2,0xFF141818);g.fill(ax,az,ax+1,az+1,0xFFFFFFFF);}
|
||||
}
|
||||
int color=ProgressionClient.identity==null?0xFFFFFFFF:0xFF000000|ProgressionClient.identity.color();
|
||||
g.fill(px-3,pz-3,px+4,pz+4,0xFF141818);g.fill(px-2,pz-2,px+3,pz+3,color);
|
||||
}
|
||||
g.text(font,"N ↑",x+7,y+7,0xFFFFFFFF);
|
||||
if(pixels==0)g.textWithWordWrap(font,SanctuaryAtlasScreen.label(readable?"empty":status),x+8,y+height/2+12,width-16,0xFFCCCCCC);
|
||||
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-14),x+7,getBottom()-16,0xFFF0EEE6);
|
||||
if(isHoveredOrFocused()) {g.fill(x,y,getRight(),y+1,0xFFD0CCAF);g.fill(x,getBottom()-1,getRight(),getBottom(),0xFFD0CCAF);}
|
||||
g.disableScissor();
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
|
||||
@Override public void close() {
|
||||
if(texture!=null){AtlasClient.send(new AtlasPayloads.Request(0,0,0,false));client.getTextureManager().release(TEXTURE);texture=null;}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** Each column owns its scrolling and reads the existing paginated contract. */
|
||||
final class PauseCommunityFeed {
|
||||
private final Screen owner;
|
||||
private final String kind;
|
||||
private final int width;
|
||||
private final LinearLayout rows=LinearLayout.vertical().spacing(5);
|
||||
private final ScrollableLayout scroll;
|
||||
private final List<JsonObject> entries=new ArrayList<>();
|
||||
private boolean needsLoad=true,more,closed,loaded;
|
||||
private int page;
|
||||
private String error="";
|
||||
PauseCommunityFeed(Screen owner,Consumer<AbstractWidget> add,int x,int y,int w,int h,String kind) {
|
||||
this.owner=owner;this.kind=kind;width=w-10;
|
||||
var heading=new PauseTextButton(x,y,w-24,20,text(kind),()->open(owner,kind,"",false));
|
||||
heading.setTooltip(Tooltip.create(text("browse")));add.accept(heading);
|
||||
add.accept(Button.builder(Component.literal("+"),b->open(owner,kind,"",true)).bounds(x+w-20,y,20,20).tooltip(Tooltip.create(text("publish"))).build());
|
||||
rows.addChild(new StringWidget(width,20,text(available()?"loading":"unavailable"),Minecraft.getInstance().font));
|
||||
scroll=new ScrollableLayout(Minecraft.getInstance(),rows,Math.max(24,h-24),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setMinWidth(width);scroll.setMinHeight(Math.max(24,h-24));scroll.setPosition(x,y+24);scroll.arrangeElements();scroll.visitWidgets(add);
|
||||
}
|
||||
boolean load() {
|
||||
if(closed || !needsLoad)return false;
|
||||
if(!request(owner,"list","","",kind,"info","","",0,page,result->{
|
||||
if(closed)return;
|
||||
if(result.has("error")) {showError(str(result,"error"));return;}
|
||||
var listing=result.getAsJsonObject("page");
|
||||
var next=page==0?new ArrayList<JsonObject>():new ArrayList<>(entries);
|
||||
for(var value:listing.getAsJsonArray("entries")) {
|
||||
var entry=value.getAsJsonObject();
|
||||
if(next.stream().noneMatch(e->str(e,"id").equals(str(entry,"id"))))next.add(entry);
|
||||
}
|
||||
boolean nextMore=flag(listing,"more");
|
||||
if(loaded && error.isEmpty() && entries.equals(next) && more==nextMore)return;
|
||||
loaded=true;error="";entries.clear();entries.addAll(next);more=nextMore;rebuild();
|
||||
}))return false;
|
||||
needsLoad=false;return true;
|
||||
}
|
||||
private void rebuild() {
|
||||
rows.removeChildren();
|
||||
var font=Minecraft.getInstance().font;
|
||||
if(entries.isEmpty() && error.isEmpty())rows.addChild(new MultiLineTextWidget(text("empty"),font).setMaxWidth(width));
|
||||
for(var entry:entries) {
|
||||
Runnable action=()->open(owner,kind,str(entry,"id"),false);
|
||||
if(kind.equals("article")) {
|
||||
var title=Component.literal((flag(entry,"pinned")?"★ ":"")+str(entry,"title"));
|
||||
var row=rows.addChild(LinearLayout.horizontal().spacing(5));
|
||||
var face=row.addChild(new PlayerFaceWidget(20,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId")))));
|
||||
face.setTooltip(Tooltip.create(Component.literal(str(entry,"authorName"))));
|
||||
row.addChild(Button.builder(title,b->action.run()).width(width-25).tooltip(Tooltip.create(title.copy().append("\n").append(str(entry,"authorName")))).build());
|
||||
} else rows.addChild(new NoticeCard(width,entry,action));
|
||||
}
|
||||
if(more && error.isEmpty())rows.addChild(Button.builder(text("more"),b->{page++;needsLoad=true;b.active=false;}).width(width).build());
|
||||
scroll.arrangeElements();
|
||||
}
|
||||
private void showError(String error) {
|
||||
this.error=error;rebuild();rows.addChild(new MultiLineTextWidget(text(error),Minecraft.getInstance().font).setMaxWidth(width));
|
||||
rows.addChild(Button.builder(text("refresh"),b->{needsLoad=true;b.active=false;}).width(width).build());scroll.arrangeElements();
|
||||
}
|
||||
void refresh() {if(page==0 && scroll.getScrollAmount()==0 && error.isEmpty())needsLoad=true;}
|
||||
void close() {closed=true;}
|
||||
private static final class NoticeCard extends AbstractButton {
|
||||
private final JsonObject entry;
|
||||
private final Runnable action;
|
||||
NoticeCard(int width,JsonObject entry,Runnable action) {super(0,0,width,84,Component.literal(str(entry,"title")+"\n"+str(entry,"body")));this.entry=entry;this.action=action;}
|
||||
@Override public void onPress(InputWithModifiers input) {action.run();}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),border=isHoveredOrFocused()?0xFFC8C0A6:0xFF727365;
|
||||
g.fill(x,y,getRight(),getBottom(),0xBA202720);
|
||||
g.fill(x,y,getRight(),y+1,border);g.fill(x,getBottom()-1,getRight(),getBottom(),border);
|
||||
g.fill(x,y,x+1,getBottom(),border);g.fill(getRight()-1,y,getRight(),getBottom(),border);
|
||||
g.text(font,font.plainSubstrByWidth(str(entry,"title"),width-14),x+7,y+7,0xFFF0EEE6);
|
||||
var lines=font.split(Component.literal(str(entry,"body")),width-14);
|
||||
for(int i=0;i<Math.min(3,lines.size());i++)g.text(font,lines.get(i),x+7,y+23+i*10,0xFFD2D6CF,false);
|
||||
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,y+height-23,0xFFAABAAA);
|
||||
g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+height-12,0xFFAABAAA);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractButton;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Quiet navigation: only the active target gains a background. */
|
||||
final class PauseTextButton extends AbstractButton {
|
||||
private final Runnable action;
|
||||
PauseTextButton(int x,int y,int width,int height,Component label,Runnable action) {super(x,y,width,height,label);this.action=action;}
|
||||
@Override public void onPress(InputWithModifiers input) {action.run();}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
if(isHoveredOrFocused())g.fill(getX(),getY(),getRight(),getBottom(),0x80424A48);
|
||||
var font=Minecraft.getInstance().font;
|
||||
if(height>24) {
|
||||
var lines=font.split(getMessage(),width-10);int count=Math.min(2,lines.size());
|
||||
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+(width-font.width(lines.get(i)))/2,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
|
||||
return;
|
||||
}
|
||||
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-10),getX()+5,getY()+(height-9)/2,active?0xFFF0EEE6:0xFF969696);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
|
||||
}
|
||||
|
||||
public static void registerClient() {
|
||||
CommunityClient.register();
|
||||
SanctuaryResourcePack.register();
|
||||
fr.koka.sanctuary.client.shader.NaturalWater.register();
|
||||
VortexCloudRenderer.register();
|
||||
|
||||
@@ -32,8 +32,14 @@ final class PbrMaterials implements AutoCloseable {
|
||||
if(name.equals("iron_block")||name.equals("gold_block")||name.equals("netherite_block")||name.contains("copper")&&!name.contains("ore")&&!name.contains("raw"))
|
||||
return new Profile(name.contains("oxidized")?.65f:.32f,name.contains("oxidized")?.35f:.85f);
|
||||
if(name.contains("polished")||name.contains("glazed")||name.equals("diamond_block")||name.equals("emerald_block")||name.equals("ruby_block")||name.equals("sapphire_block"))return new Profile(.42f,0);
|
||||
// Dry soil keeps its normal relief without a broad white specular coating.
|
||||
if(java.util.Set.of("dirt","coarse_dirt","rooted_dirt","grass_block","dirt_path","farmland","podzol","mycelium").contains(name))return new Profile(.98f,0);
|
||||
if(name.contains("wool")||name.contains("leaves"))return new Profile(.98f,0);
|
||||
if(name.contains("planks")||name.contains("log")||name.contains("wood"))return new Profile(.88f,0);
|
||||
// All wooden shapes share a matte response, including Nether stems/hyphae.
|
||||
String woodName=name.startsWith("stripped_")?name.substring(9):name;
|
||||
for(String wood:new String[]{"oak","spruce","birch","jungle","acacia","dark_oak","mangrove","cherry","pale_oak","bamboo","crimson","warped"})
|
||||
if(woodName.startsWith(wood+"_")&&Set.of("planks","log","wood","stem","hyphae","slab","stairs","fence","fence_gate","door","trapdoor","button","pressure_plate","sign","wall_sign","hanging_sign","wall_hanging_sign","block","mosaic","mosaic_slab","mosaic_stairs").contains(woodName.substring(wood.length()+1)))return new Profile(.98f,0);
|
||||
if(name.contains("planks")||name.contains("log")||name.contains("wood"))return new Profile(.98f,0);
|
||||
return new Profile(.82f,0);
|
||||
}
|
||||
Slot slot(TextureAtlasSprite sprite,BlockState state){
|
||||
|
||||
@@ -48,7 +48,7 @@ final class PbrTerrain implements AutoCloseable {
|
||||
long frame=mc.level.getGameTime();var active=new ArrayList<Section>();
|
||||
for(var render:mc.levelRenderer.visibleSections()){
|
||||
long key=render.getSectionNode();var pos=render.getRenderOrigin();
|
||||
if(pos.offset(8,8,8).distToCenterSqr(camera)>Math.pow(VanillaLight.pbrDistance()+16,2))continue;
|
||||
if(pos.offset(8,8,8).distToCenterSqr(camera)>Math.pow(VanillaLight.materialDistance()+16,2))continue;
|
||||
var chunk=mc.level.getChunkSource().getChunk(pos.getX()>>4,pos.getZ()>>4,ChunkStatus.FULL,false);
|
||||
if(chunk==null)continue;
|
||||
var storage=chunk.getSection(mc.level.getSectionIndex(pos.getY()));
|
||||
|
||||
@@ -90,8 +90,8 @@ public final class PixelReflections {
|
||||
if(uniform==null)uniform=new MappableRingBuffer(()->"Sanctuary reflection parameters",GpuBuffer.USAGE_UNIFORM|GpuBuffer.USAGE_MAP_WRITE,160);
|
||||
uniform.rotate();try(var map=uniform.currentBuffer().map(false,true)){
|
||||
Std140Builder.intoBuffer(map.data()).putMat4f(vp).putMat4f(new Matrix4f(vp).invert())
|
||||
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.ssrIntensity()/100f,VanillaLight.pbrIntensity()/100f,VanillaLight.pbrDistance())
|
||||
.putVec4(128,32,0,0);
|
||||
.putVec4(RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,VanillaLight.ssrIntensity()/100f,VanillaLight.pbrIntensity()/100f,VanillaLight.ssrDistance())
|
||||
.putVec4(Math.max(128,VanillaLight.ssrDistance()),32,VanillaLight.materialDistance(),0);
|
||||
}
|
||||
int max=java.util.stream.Stream.concat(solid.stream(),transparent.stream()).mapToInt(PbrTerrain.Entry::indices).max().orElse(0);
|
||||
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).requestIndexCount(max);
|
||||
|
||||
@@ -66,13 +66,16 @@ public final class ProceduralPbr {
|
||||
if(lunar)sun=PixelShadows.sunDirection(sky.moonAngle);
|
||||
float moon=sky.moonPhase==null?1:(float)(.5+.5*Math.cos(sky.moonPhase.index()*Math.PI/4));
|
||||
float strength=(lunar?.12f*moon:1)*sky.rainBrightness*Math.clamp((sun.y-.04f)/.16f,0,1);
|
||||
var sunColour=lunar?new org.joml.Vector3f(1):SolarColour.tint(sky);
|
||||
boolean shadow=PixelShadows.applied();
|
||||
scene.rotate();try(var mapped=scene.currentBuffer().map(false,true)){
|
||||
Std140Builder.intoBuffer(mapped.data()).putMat4f(new Matrix4f(vp).invert())
|
||||
.putVec4(sun.x,sun.y,sun.z,strength)
|
||||
.putVec4(VanillaLight.pbrIntensity()/100f,RenderSystem.getDevice().getDeviceInfo().isZZeroToOne()?1:0,shadow?1:0,VanillaLight.pbrDistance())
|
||||
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,ColoredLights.directionalReady()?1:0,0,0)
|
||||
.putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0);
|
||||
.putVec4(fog.renderDistanceStart,fog.renderDistanceEnd,fog.environmentalStart,fog.environmentalEnd).putVec4(fog.color.w,ColoredLights.directionalReady()?1:0,lunar?0:1,0)
|
||||
.putVec4(sunColour.x*sunColour.x,sunColour.y*sunColour.y,sunColour.z*sunColour.z,1)
|
||||
.putVec4((float)(camera.pos.x%256),(float)(camera.pos.y%256),(float)(camera.pos.z%256),0)
|
||||
.putVec4(0,0,0,0).putVec4(0,0,0,0);
|
||||
}
|
||||
var transforms=new ArrayList<GpuBufferSlice>(entries.size());int max=0;
|
||||
for(var entry:entries){var p=entry.origin();var offset=new Vector3f((float)(p.getX()-camera.pos.x),(float)(p.getY()-camera.pos.y),(float)(p.getZ()-camera.pos.z));
|
||||
|
||||
+6
-3
@@ -29,12 +29,15 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pbr",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.pbr_help")),VanillaLight.pbr(),VanillaLight::setPbr));
|
||||
slider("pbr_intensity",VanillaLight.pbrIntensity(),VanillaLight::setPbrIntensity);
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.pbr_distance",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.pbr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.pbr_distance_value",value),
|
||||
new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.pbrDistance(),VanillaLight::setPbrDistance));
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.ssr",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.ssr_help")),VanillaLight.ssr(),VanillaLight::setSsr));
|
||||
slider("ssr_intensity",VanillaLight.ssrIntensity(),VanillaLight::setSsrIntensity);
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.pbr_distance",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.pbr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.pbr_distance_value",value),
|
||||
new OptionInstance.Enum<>(List.of(16,32,64,128),Codec.INT),VanillaLight.pbrDistance(),VanillaLight::setPbrDistance));
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.ssr_distance",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.ssr_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.ssr_distance_value",value),
|
||||
new OptionInstance.Enum<>(List.of(16,32,64,128,256),Codec.INT),VanillaLight.ssrDistance(),VanillaLight::setSsrDistance));
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.colored_lights",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.colored_lights_help")),VanillaLight.coloredLights(),VanillaLight::setColoredLights));
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.warm_torches",OptionInstance.cachedConstantTooltip(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import net.minecraft.client.renderer.state.level.SkyRenderState;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/** Uses the exact colour and fade already extracted for Minecraft's horizon. */
|
||||
public final class SolarColour {
|
||||
private SolarColour(){}
|
||||
public static Vector3f tint(SkyRenderState sky){
|
||||
var colour=sky.sunriseAndSunsetColor;
|
||||
if(colour==null)return new Vector3f(1);
|
||||
float weight=Math.clamp(colour.w(),0,1);
|
||||
return new Vector3f(1).lerp(new Vector3f(colour.x(),colour.y(),colour.z()),weight);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ public final class VanillaLight {
|
||||
boolean lensFlare=true;
|
||||
int lensFlareIntensity=30;
|
||||
boolean pbr=true;
|
||||
int pbrIntensity=80;
|
||||
int pbrDistance=32;
|
||||
boolean ssr=false;
|
||||
int pbrIntensity=50;
|
||||
int pbrBalanceVersion;
|
||||
int pbrDistance=64;
|
||||
int ssrDistance=64;
|
||||
int reflectionDistanceVersion;
|
||||
boolean ssr=true;
|
||||
int ssrIntensity=20;
|
||||
boolean ssgi=true;
|
||||
boolean coloredLights=true;
|
||||
@@ -63,7 +66,13 @@ public final class VanillaLight {
|
||||
preferences.sunshaftIntensity=Math.clamp(preferences.sunshaftIntensity,0,100);
|
||||
preferences.lensFlareIntensity=Math.clamp(preferences.lensFlareIntensity,0,100);
|
||||
preferences.ssrIntensity=Math.clamp(preferences.ssrIntensity,0,100);
|
||||
if(preferences.reflectionDistanceVersion<1&&preferences.pbrDistance==32)preferences.pbrDistance=64;
|
||||
preferences.reflectionDistanceVersion=1;
|
||||
preferences.pbrDistance=normalizePbrDistance(preferences.pbrDistance);
|
||||
preferences.ssrDistance=normalizePbrDistance(preferences.ssrDistance);
|
||||
// Migrate the previous 80% baseline once; later explicit 80% choices persist.
|
||||
if(preferences.pbrBalanceVersion<1&&preferences.pbrIntensity==80)preferences.pbrIntensity=50;
|
||||
preferences.pbrBalanceVersion=1;
|
||||
preferences.pbrIntensity=Math.clamp(preferences.pbrIntensity,0,100);
|
||||
preferences.coloredLightIntensity=Math.clamp(preferences.coloredLightIntensity,0,100);
|
||||
preferences.coloredLightDistance=normalizeColoredLightDistance(preferences.coloredLightDistance);
|
||||
@@ -110,9 +119,12 @@ public final class VanillaLight {
|
||||
public static int ssrIntensity(){enabled();return preferences.ssrIntensity;}
|
||||
public static void setSsr(boolean value){enabled();preferences.ssr=value;save();}
|
||||
public static void setSsrIntensity(int value){enabled();preferences.ssrIntensity=Math.clamp(value,0,100);save();}
|
||||
public static int ssrDistance(){enabled();return preferences.ssrDistance;}
|
||||
public static void setSsrDistance(int value){enabled();preferences.ssrDistance=normalizePbrDistance(value);save();}
|
||||
static int materialDistance(){return Math.max(pbrDistance(),ssr()&&ssrIntensity()>0?ssrDistance():0);}
|
||||
public static int pbrDistance(){enabled();return preferences.pbrDistance;}
|
||||
public static void setPbrDistance(int value){enabled();preferences.pbrDistance=normalizePbrDistance(value);save();}
|
||||
static int normalizePbrDistance(int value){return value<24?16:value<48?32:value<96?64:128;}
|
||||
static int normalizePbrDistance(int value){return value<24?16:value<48?32:value<96?64:value<192?128:256;}
|
||||
public static boolean pbr(){enabled();return preferences.pbr;}
|
||||
public static int pbrIntensity(){enabled();return preferences.pbrIntensity;}
|
||||
public static void setPbr(boolean value){enabled();preferences.pbr=value;save();}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Versioned community rules shared by both persistence adapters. */
|
||||
public final class Community {
|
||||
public static final int VERSION = 2, PAGE_SIZE = 10;
|
||||
public static final Set<String> KINDS = Set.of("article", "notice", "bulletin", "reply");
|
||||
public static final Set<String> CATEGORIES = Set.of("info", "work", "need", "event");
|
||||
private Community() { }
|
||||
public record Actor(String id, String name, boolean moderator) {
|
||||
public Actor { uuid(id); text(name, 64); }
|
||||
}
|
||||
public record Entry(String id, String parentId, String kind, String category,
|
||||
String authorId, String authorName, String title, String body,
|
||||
String status, boolean pinned, int revision, long createdAt, long updatedAt, String photo) {
|
||||
public Entry {
|
||||
photo = Objects.requireNonNullElse(photo, "");
|
||||
if (photo.length() > CommunityPhoto.MAX_TEXT || (!photo.isEmpty() && !kind.equals("article"))) fail("photo_invalid");
|
||||
uuid(id); uuid(authorId); if (!parentId.isEmpty()) uuid(parentId);
|
||||
if (!KINDS.contains(kind) || !CATEGORIES.contains(category)
|
||||
|| !Set.of("open", "closed", "hidden").contains(status) || revision < 1
|
||||
|| createdAt < 0 || updatedAt < createdAt || kind.equals("reply") != !parentId.isEmpty()) fail("invalid");
|
||||
text(authorName, 64); if (!kind.equals("reply")) text(title, 120);
|
||||
else if (!title.isEmpty()) fail("invalid");
|
||||
text(body, kind.equals("reply") ? 1000 : 4000);
|
||||
}
|
||||
}
|
||||
public record Change(String action, String id, String parentId, String kind, String category,
|
||||
String title, String body, int revision, String photo) {
|
||||
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision) {
|
||||
this(action,id,parentId,kind,category,title,body,revision,null);
|
||||
}
|
||||
}
|
||||
public record Page(List<Entry> entries, int page, boolean more) { }
|
||||
public record ThreadView(Entry entry, Page replies) { }
|
||||
public static final class Refused extends RuntimeException {
|
||||
public final String code;
|
||||
public Refused(String code) { super(code); this.code = code; }
|
||||
}
|
||||
public static void fail(String code) { throw new Refused(code); }
|
||||
public static String uuid(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) fail("invalid");
|
||||
return value;
|
||||
}
|
||||
public static String text(String value, int max) {
|
||||
if (value == null || value.isBlank() || value.codePointCount(0, value.length()) > max
|
||||
|| value.codePoints().anyMatch(c -> (Character.isISOControl(c) && c != '\n' && c != '\t')
|
||||
|| c == 0xA7 || c >= 0xD800 && c <= 0xDFFF)) fail("invalid");
|
||||
return value;
|
||||
}
|
||||
public static Entry apply(CommunityStore.Transaction tx, Actor actor, Change change, long now) throws Exception {
|
||||
uuid(change.id());
|
||||
if (change.action().equals("create")) {
|
||||
if (!KINDS.contains(change.kind()) || !CATEGORIES.contains(change.category())) fail("invalid");
|
||||
if (change.kind().equals("bulletin") && !actor.moderator()) fail("forbidden");
|
||||
String parent = change.parentId();
|
||||
if (change.kind().equals("reply")) {
|
||||
uuid(parent);
|
||||
Entry p = visible(tx.get(parent));
|
||||
if (!Set.of("article", "notice").contains(p.kind())) fail("invalid");
|
||||
// Lock parent first on both creation and retry, including already committed replies.
|
||||
} else if (!parent.isEmpty()) fail("invalid");
|
||||
Entry existing = tx.get(change.id());
|
||||
if (existing != null) {
|
||||
if (!existing.authorId().equals(actor.id()) || !existing.kind().equals(change.kind())
|
||||
|| !existing.parentId().equals(parent) || !existing.category().equals(change.category())
|
||||
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())
|
||||
|| !existing.photo().equals(Objects.requireNonNullElse(change.photo(),""))) fail("conflict");
|
||||
return visible(existing);
|
||||
}
|
||||
if (!parent.isEmpty() && !tx.get(parent).status().equals("open")) fail("closed");
|
||||
Entry entry = new Entry(change.id(), parent, change.kind(), change.category(), actor.id(), actor.name(),
|
||||
change.title(), change.body(), "open", false, 1, now, now, photo(change.kind(),change.photo()));
|
||||
tx.save(entry, true); return entry;
|
||||
}
|
||||
Entry old = visible(tx.get(change.id()));
|
||||
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
|
||||
if (old.revision() != change.revision()) fail("conflict");
|
||||
boolean own = old.authorId().equals(actor.id());
|
||||
String title = old.title(), body = old.body(), status = old.status(), photo = old.photo(); boolean pinned = old.pinned();
|
||||
switch (change.action()) {
|
||||
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); photo = photo(old.kind(),change.photo()==null?old.photo():change.photo()); }
|
||||
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
|
||||
case "close", "reopen" -> {
|
||||
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
|
||||
status = change.action().equals("close") ? "closed" : "open";
|
||||
}
|
||||
case "pin" -> { if (!actor.moderator() || old.kind().equals("reply")) fail("forbidden"); pinned = !pinned; }
|
||||
default -> fail("invalid");
|
||||
}
|
||||
Entry next = new Entry(old.id(), old.parentId(), old.kind(), old.category(), old.authorId(), old.authorName(),
|
||||
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo);
|
||||
tx.save(next, false); return next;
|
||||
}
|
||||
private static String photo(String kind,String photo) {
|
||||
if(kind.equals("article"))return CommunityPhoto.validate(photo);
|
||||
if(photo!=null && !photo.isEmpty())fail("photo_invalid");
|
||||
return "";
|
||||
}
|
||||
public static Entry visible(Entry entry) {
|
||||
if (entry == null || entry.status().equals("hidden")) fail("missing");
|
||||
return entry;
|
||||
}
|
||||
public static int page(int page) { if (page < 0 || page > 100000) fail("invalid"); return page; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.GsonBuilder;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CommunityConfig(String mode, String serverId, String jdbcUrl, String user, String passwordEnv) {
|
||||
public CommunityConfig {
|
||||
if (!Set.of("file", "database").contains(mode)) throw new IllegalArgumentException("Community mode");
|
||||
Community.uuid(serverId);
|
||||
if (jdbcUrl == null || !jdbcUrl.startsWith("jdbc:mariadb://") || user == null
|
||||
|| passwordEnv == null || !passwordEnv.matches("[A-Z][A-Z0-9_]+")) throw new IllegalArgumentException("Community database configuration");
|
||||
}
|
||||
public static CommunityConfig load(Path path) throws Exception {
|
||||
var gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
byte[] bytes = AtomicJson.read(path, 16384);
|
||||
if (bytes != null) {
|
||||
var root = AtomicJson.parse(new String(bytes, StandardCharsets.UTF_8));
|
||||
AtomicJson.fields(root,"mode","serverId","jdbcUrl","user","passwordEnv");
|
||||
return gson.fromJson(root, CommunityConfig.class);
|
||||
}
|
||||
var config = new CommunityConfig("file", UUID.randomUUID().toString(), "jdbc:mariadb://localhost:3306/sanctuary?sslMode=verify-full", "sanctuary_game", "SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
AtomicJson.write(path, null, gson.toJson(config), 16384); return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.operator.OperatorService;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.*;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
public final class CommunityNetwork {
|
||||
private static final Gson JSON = new Gson();
|
||||
private static final Map<MinecraftServer, Runtime> RUNTIMES = new ConcurrentHashMap<>();
|
||||
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger("sanctuary-community");
|
||||
public record Request(int sequence, String action, String id, String parentId, String kind,
|
||||
String category, String title, String body, int revision, int page) implements CustomPacketPayload {
|
||||
public static final Type<Request> TYPE = new Type<>(SanctuaryMod.id("community_request_v1"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, Request> CODEC = CustomPacketPayload.codec((p,b) -> {
|
||||
b.writeVarInt(p.sequence); b.writeUtf(p.action,16); b.writeUtf(p.id,36); b.writeUtf(p.parentId,36);
|
||||
b.writeUtf(p.kind,16); b.writeUtf(p.category,16); b.writeUtf(p.title,240); b.writeUtf(p.body,8000);
|
||||
b.writeVarInt(p.revision); b.writeVarInt(p.page);
|
||||
}, b -> new Request(b.readVarInt(),b.readUtf(16),b.readUtf(36),b.readUtf(36),b.readUtf(16),b.readUtf(16),b.readUtf(240),b.readUtf(8000),b.readVarInt(),b.readVarInt()));
|
||||
public Type<Request> type() { return TYPE; }
|
||||
}
|
||||
public record Response(int sequence, String json) implements CustomPacketPayload {
|
||||
public static final Type<Response> TYPE = new Type<>(SanctuaryMod.id("community_response_v1"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, Response> CODEC = CustomPacketPayload.codec(
|
||||
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,262144); }, b -> new Response(b.readVarInt(),b.readUtf(262144)));
|
||||
public Type<Response> type() { return TYPE; }
|
||||
}
|
||||
private CommunityNetwork() { }
|
||||
public static void register() {
|
||||
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE, Request.CODEC);
|
||||
PayloadTypeRegistry.clientboundPlay().register(Response.TYPE, Response.CODEC);
|
||||
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE, (r,c) -> c.server().execute(() -> request(c.player(),r)));
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { var runtime = RUNTIMES.remove(server); if (runtime != null) runtime.stop(); });
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
|
||||
var runtime = RUNTIMES.get(server); if (runtime != null) { runtime.rates.remove(handler.player.getUUID()); runtime.photos.remove(handler.player.getUUID()); }
|
||||
});
|
||||
}
|
||||
private static void send(ServerPlayer player, Request request, JsonObject data) {
|
||||
if (!player.isRemoved() && ServerPlayNetworking.canSend(player, Response.TYPE))
|
||||
ServerPlayNetworking.send(player, new Response(request.sequence(), data.toString()));
|
||||
}
|
||||
private static JsonObject error(String message) { var data = new JsonObject(); data.addProperty("error",message); return data; }
|
||||
private static void request(ServerPlayer player, Request request) {
|
||||
var server = player.level().getServer();
|
||||
var runtime = RUNTIMES.computeIfAbsent(server, Runtime::new);
|
||||
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread","photo").contains(request.action());
|
||||
long[] rates = runtime.rates.computeIfAbsent(player.getUUID(), key -> new long[2]); int index = reading ? 0 : 1;
|
||||
if (runtime.pending.contains(player.getUUID()) || now - rates[index] < (reading ? 100_000_000L : 1_000_000_000L)) { send(player,request,error("busy")); return; }
|
||||
rates[index] = now;
|
||||
if (request.action().equals("link") && !server.usesAuthentication()) { send(player,request,error("online_required")); return; }
|
||||
var actor = new Actor(player.getStringUUID(), player.getGameProfile().name(), OperatorService.allowed(player));
|
||||
runtime.pending.add(player.getUUID());
|
||||
try {
|
||||
runtime.executor.execute(() -> {
|
||||
JsonObject result;
|
||||
try {
|
||||
if (System.nanoTime() - now > 10_000_000_000L) fail("busy");
|
||||
var store = runtime.store(); result = new JsonObject(); result.addProperty("moderator",actor.moderator());
|
||||
result.addProperty("serverTime",System.currentTimeMillis()); result.addProperty("mode",runtime.config.mode());
|
||||
switch (request.action()) {
|
||||
case "overview" -> {
|
||||
result.add("articles", JSON.toJsonTree(store.list("article","",0).entries().stream().limit(3).toList()));
|
||||
result.add("notices", JSON.toJsonTree(store.list("notice","",0).entries().stream().limit(3).toList()));
|
||||
result.add("bulletins", JSON.toJsonTree(store.list("bulletin","",0).entries().stream().limit(1).toList()));
|
||||
}
|
||||
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
|
||||
case "thread" -> result.add("thread",JSON.toJsonTree(store.thread(request.id(),request.page())));
|
||||
case "photo" -> {
|
||||
long instant=System.currentTimeMillis();
|
||||
runtime.photos.values().removeIf(p -> instant-p.started>120000);
|
||||
if(request.page()==0) {
|
||||
if(runtime.photos.size()>=128 && !runtime.photos.containsKey(player.getUUID()))fail("busy");
|
||||
runtime.photos.put(player.getUUID(),new CommunityPhoto.Transfer(request.id(),request.revision(),instant));
|
||||
}
|
||||
var transfer=runtime.photos.get(player.getUUID());
|
||||
if(transfer==null)fail("photo_required");
|
||||
transfer.append(request.id(),request.page(),request.revision(),request.body(),instant);
|
||||
result.addProperty("uploaded",request.page()+1);
|
||||
}
|
||||
case "link" -> { store.link(actor,request.body()); result.addProperty("linked",true); }
|
||||
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision(),runtime.photo(player.getUUID(),request)))));
|
||||
}
|
||||
if(request.action().equals("list") || request.action().equals("overview"))omitPhotos(result);
|
||||
} catch (Refused e) { result = error(e.code); }
|
||||
catch (Exception e) { result = error("unavailable"); LOG.warn("Community operation unavailable ({})",e.getClass().getSimpleName()); }
|
||||
JsonObject response = result;
|
||||
server.execute(() -> {
|
||||
runtime.pending.remove(player.getUUID());
|
||||
if (RUNTIMES.get(server) == runtime && server.getPlayerList().getPlayer(player.getUUID()) == player) send(player,request,response);
|
||||
});
|
||||
});
|
||||
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
|
||||
}
|
||||
public static void omitPhotos(JsonElement value) {
|
||||
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitPhotos);
|
||||
else if(value.isJsonObject()) {
|
||||
var object=value.getAsJsonObject();
|
||||
if(object.has("photo")) {object.addProperty("hasPhoto",!object.get("photo").getAsString().isEmpty());object.remove("photo");}
|
||||
object.entrySet().forEach(e -> omitPhotos(e.getValue()));
|
||||
}
|
||||
}
|
||||
private static final class Runtime {
|
||||
final MinecraftServer server;
|
||||
final ThreadPoolExecutor executor = new ThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(32),
|
||||
task -> { var thread = new Thread(task,"Sanctuary community"); thread.setDaemon(true); return thread; });
|
||||
final Map<UUID,long[]> rates = new HashMap<>();
|
||||
final Set<UUID> pending = new HashSet<>();
|
||||
final Map<UUID,CommunityPhoto.Transfer> photos=new ConcurrentHashMap<>();
|
||||
String photo(UUID player,Request request) {
|
||||
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("article"))return null;
|
||||
var transfer=photos.get(player);
|
||||
if(transfer==null || !transfer.id.equals(request.id()))return null;
|
||||
return transfer.value(request.id(),System.currentTimeMillis());
|
||||
}
|
||||
CommunityConfig config;
|
||||
CommunityStore store;
|
||||
Runtime(MinecraftServer server) { this.server = server; }
|
||||
CommunityStore store() throws Exception {
|
||||
if (store != null) return store;
|
||||
config = CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
if (config.mode().equals("file")) store = new FileCommunityStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
else {
|
||||
String password = System.getenv(config.passwordEnv());
|
||||
if (password == null) throw new IllegalArgumentException("Missing database credential environment variable");
|
||||
store = new SqlCommunityStore(config.jdbcUrl(),config.user(),password,config.serverId());
|
||||
}
|
||||
return store;
|
||||
}
|
||||
void stop() { executor.shutdownNow(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.util.Base64;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.stream.MemoryCacheImageInputStream;
|
||||
import javax.imageio.stream.MemoryCacheImageOutputStream;
|
||||
import static fr.koka.sanctuary.community.Community.fail;
|
||||
|
||||
/** Bounded, portable JPEG copies; originals and their metadata never leave the screenshot folder. */
|
||||
public final class CommunityPhoto {
|
||||
public static final int MAX_BYTES=48*1024, MAX_TEXT=65536, WIDTH=960, HEIGHT=540;
|
||||
private CommunityPhoto() { }
|
||||
public static final class Transfer {
|
||||
public final String id;
|
||||
public final long started;
|
||||
private final int parts;
|
||||
private int next;
|
||||
private final StringBuilder data=new StringBuilder();
|
||||
public Transfer(String id,int parts,long now) {
|
||||
this.id=Community.uuid(id);this.parts=parts;this.started=now;
|
||||
if(parts<1 || parts>9)fail("photo_invalid");
|
||||
}
|
||||
public void append(String id,int part,int total,String value,long now) {
|
||||
if(!this.id.equals(id) || part!=next || part>=parts || total!=parts || now-started>120000
|
||||
|| value.isEmpty() || value.length()>8000 || data.length()+value.length()>MAX_TEXT)fail("photo_invalid");
|
||||
data.append(value);next++;
|
||||
}
|
||||
public String value(String id,long now) {
|
||||
if(!this.id.equals(id) || next!=parts || now-started>120000)fail("photo_required");
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
public static BufferedImage decode(byte[] bytes,int maxWidth,int maxHeight) throws IOException {
|
||||
try(var stream=new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) {
|
||||
var readers=ImageIO.getImageReaders(stream);
|
||||
if(!readers.hasNext())throw new IOException("Invalid image");
|
||||
var reader=readers.next();
|
||||
try {
|
||||
reader.setInput(stream,true,true);
|
||||
int w=reader.getWidth(0),h=reader.getHeight(0);
|
||||
if(w<1 || h<1 || w>maxWidth || h>maxHeight || (long)w*h>32_000_000)throw new IOException("Image dimensions");
|
||||
var image=reader.read(0);
|
||||
if(image==null)throw new IOException("Invalid image");
|
||||
return image;
|
||||
} finally {reader.dispose();}
|
||||
}
|
||||
}
|
||||
public static String validate(String value) {
|
||||
if(value==null || value.isEmpty())fail("photo_required");
|
||||
try {
|
||||
if(value.length()>MAX_TEXT)throw new IOException("Image size");
|
||||
byte[] bytes=Base64.getDecoder().decode(value);
|
||||
if(bytes.length>MAX_BYTES || bytes.length<4 || bytes[0]!=(byte)0xff || bytes[1]!=(byte)0xd8
|
||||
|| bytes[bytes.length-2]!=(byte)0xff || bytes[bytes.length-1]!=(byte)0xd9
|
||||
|| !Base64.getEncoder().encodeToString(bytes).equals(value))throw new IOException("Invalid JPEG");
|
||||
decode(bytes,WIDTH,HEIGHT);
|
||||
return value;
|
||||
} catch(IOException | IllegalArgumentException e) {fail("photo_invalid");return "";}
|
||||
}
|
||||
public static String encode(BufferedImage source,int maxWidth,int maxHeight) throws IOException {
|
||||
double scale=Math.min(1,Math.min((double)maxWidth/source.getWidth(),(double)maxHeight/source.getHeight()));
|
||||
var image=new BufferedImage(Math.max(1,(int)(source.getWidth()*scale)),Math.max(1,(int)(source.getHeight()*scale)),BufferedImage.TYPE_INT_RGB);
|
||||
var graphics=image.createGraphics();
|
||||
graphics.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
graphics.drawImage(source,0,0,image.getWidth(),image.getHeight(),null);graphics.dispose();
|
||||
for(float quality:new float[]{.85f,.7f,.55f,.4f,.25f}) {
|
||||
var bytes=new ByteArrayOutputStream();
|
||||
var writer=ImageIO.getImageWritersByFormatName("jpeg").next();
|
||||
try(var out=new MemoryCacheImageOutputStream(bytes)) {
|
||||
writer.setOutput(out);var params=writer.getDefaultWriteParam();
|
||||
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);params.setCompressionQuality(quality);
|
||||
writer.write(null,new javax.imageio.IIOImage(image,null,null),params);
|
||||
} finally {writer.dispose();}
|
||||
if(bytes.size()<=MAX_BYTES)return Base64.getEncoder().encodeToString(bytes.toByteArray());
|
||||
}
|
||||
if(maxWidth>240)return encode(image,maxWidth/2,maxHeight/2);
|
||||
throw new IOException("Image size");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.time.Clock;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
public interface CommunityStore extends AutoCloseable {
|
||||
interface Transaction {
|
||||
Entry get(String id) throws Exception;
|
||||
void save(Entry entry, boolean insert) throws Exception;
|
||||
}
|
||||
Page list(String kind, String parent, int page) throws Exception;
|
||||
ThreadView thread(String id, int page) throws Exception;
|
||||
Entry change(Actor actor, Change change) throws Exception;
|
||||
default void link(Actor actor, String code) throws Exception { fail("database_only"); }
|
||||
@Override default void close() throws Exception { }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.Clock;
|
||||
import java.util.*;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
public final class FileCommunityStore implements CommunityStore {
|
||||
private static final int LIMIT = 64 * 1024 * 1024;
|
||||
private static final Gson JSON = new Gson();
|
||||
private final Path path;
|
||||
private final String serverId;
|
||||
private final Clock clock;
|
||||
private byte[] previous;
|
||||
private boolean legacy;
|
||||
private Map<String, Entry> entries = new LinkedHashMap<>();
|
||||
public FileCommunityStore(Path path, String serverId) throws Exception { this(path, serverId, Clock.systemUTC()); }
|
||||
public FileCommunityStore(Path path, String serverId, Clock clock) throws Exception {
|
||||
this.path = path; this.serverId = uuid(serverId); this.clock = clock;
|
||||
previous = AtomicJson.read(path, LIMIT);
|
||||
if (previous != null) {
|
||||
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
AtomicJson.fields(root, "version", "serverId", "entries");
|
||||
legacy = root.get("version").getAsInt()==1;
|
||||
if ((!legacy && root.get("version").getAsInt() != VERSION) || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
|
||||
for (var value : root.getAsJsonArray("entries")) {
|
||||
Entry entry = JSON.fromJson(value, Entry.class);
|
||||
if (entries.put(entry.id(), entry) != null) fail("invalid");
|
||||
}
|
||||
for (Entry entry : entries.values()) if (!entry.parentId().isEmpty()) {
|
||||
Entry parent = entries.get(entry.parentId());
|
||||
if (parent == null || !Set.of("article", "notice").contains(parent.kind())) fail("invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@Override public synchronized Page list(String kind, String parent, int page) {
|
||||
page(page); if (!KINDS.contains(kind)) fail("invalid");
|
||||
if (!parent.isEmpty()) visible(entries.get(parent));
|
||||
Comparator<Entry> order = Comparator.comparingLong(Entry::createdAt).thenComparing(Entry::id);
|
||||
if (kind.equals("bulletin")) order = order.reversed();
|
||||
else if (!kind.equals("reply")) order = Comparator.comparing(Entry::pinned).reversed().thenComparing(order.reversed());
|
||||
var found = entries.values().stream().filter(e -> e.kind().equals(kind) && e.parentId().equals(parent)
|
||||
&& !e.status().equals("hidden")).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
|
||||
return new Page(found.subList(0, Math.min(PAGE_SIZE, found.size())), page, found.size() > PAGE_SIZE);
|
||||
}
|
||||
@Override public synchronized ThreadView thread(String id, int page) {
|
||||
Entry entry = visible(entries.get(uuid(id)));
|
||||
if (entry.kind().equals("reply")) fail("missing");
|
||||
return new ThreadView(entry, list("reply", id, page));
|
||||
}
|
||||
@Override public synchronized Entry change(Actor actor, Change change) throws Exception {
|
||||
var candidate = new LinkedHashMap<>(entries);
|
||||
boolean[] dirty = {false};
|
||||
Entry result = Community.apply(new Transaction() {
|
||||
public Entry get(String id) { return candidate.get(id); }
|
||||
public void save(Entry entry, boolean insert) { candidate.put(entry.id(), entry); dirty[0] = true; }
|
||||
}, actor, change, clock.millis());
|
||||
if (dirty[0]) {
|
||||
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
|
||||
root.add("entries", JSON.toJsonTree(candidate.values()));
|
||||
if (legacy) {
|
||||
Path backup=path.resolveSibling(path.getFileName()+".v1.bak");
|
||||
if (Files.exists(backup)) {
|
||||
if (!Arrays.equals(AtomicJson.read(backup,LIMIT),previous)) throw new java.io.IOException("Different migration backup");
|
||||
} else Files.write(backup,previous,StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
|
||||
entries = candidate; legacy=false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.*;
|
||||
import java.time.Clock;
|
||||
import java.util.*;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
/** JDBC stays on the community worker. No credentials or raw SQL errors reach players. */
|
||||
public final class SqlCommunityStore implements CommunityStore {
|
||||
private final String url, user, password, serverId;
|
||||
private final Clock clock;
|
||||
public SqlCommunityStore(String url, String user, String password, String serverId) throws Exception {
|
||||
this(url, user, password, serverId, Clock.systemUTC());
|
||||
}
|
||||
public SqlCommunityStore(String url, String user, String password, String serverId, Clock clock) throws Exception {
|
||||
this.url = url; this.user = user; this.password = password; this.serverId = uuid(serverId); this.clock = clock;
|
||||
Class.forName("org.mariadb.jdbc.Driver");
|
||||
try (var c = connect(); var s = c.prepareStatement("SELECT version FROM sanctuary_community_schema WHERE id=1")) {
|
||||
try (var result = s.executeQuery()) { if (!result.next() || result.getInt(1) != VERSION) fail("schema"); }
|
||||
}
|
||||
}
|
||||
private Connection connect() throws SQLException {
|
||||
var properties = new Properties(); properties.setProperty("user", user); properties.setProperty("password", password);
|
||||
properties.setProperty("connectTimeout", "2000"); properties.setProperty("socketTimeout", "5000");
|
||||
return DriverManager.getConnection(url, properties);
|
||||
}
|
||||
private PreparedStatement query(Connection c, String sql, Object... values) throws SQLException {
|
||||
var s = c.prepareStatement(sql); s.setQueryTimeout(4);
|
||||
for (int i = 0; i < values.length; i++) s.setObject(i + 1, values[i]);
|
||||
return s;
|
||||
}
|
||||
private Entry row(ResultSet r) throws SQLException {
|
||||
return new Entry(r.getString("id"), Objects.requireNonNullElse(r.getString("parent_id"), ""), r.getString("kind"),
|
||||
r.getString("category"), r.getString("author_id"), r.getString("author_name"), r.getString("title"),
|
||||
r.getString("body"), r.getString("status"), r.getBoolean("pinned"), r.getInt("revision"),
|
||||
r.getLong("created_at"), r.getLong("updated_at"), r.getString("photo"));
|
||||
}
|
||||
private Entry get(Connection c, String id, boolean lock) throws SQLException {
|
||||
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
|
||||
var r = s.executeQuery()) { return r.next() ? row(r) : null; }
|
||||
}
|
||||
private Page list(Connection c, String kind, String parent, int page) throws Exception {
|
||||
page(page); if (!KINDS.contains(kind)) fail("invalid");
|
||||
if (!parent.isEmpty()) visible(get(c, uuid(parent), false));
|
||||
String parentSql = parent.isEmpty() ? "parent_id IS NULL" : "parent_id=?";
|
||||
String order = kind.equals("reply") ? "created_at ASC,id ASC" : kind.equals("bulletin") ? "created_at DESC,id DESC" : "pinned DESC,created_at DESC,id DESC";
|
||||
var values = new ArrayList<Object>(List.of(serverId, kind)); if (!parent.isEmpty()) values.add(parent);
|
||||
values.add(PAGE_SIZE + 1); values.add(page * PAGE_SIZE);
|
||||
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND kind=? AND " + parentSql
|
||||
+ " AND status<>'hidden' ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
|
||||
var entries = new ArrayList<Entry>(); while (r.next()) entries.add(row(r));
|
||||
return new Page(List.copyOf(entries.subList(0, Math.min(PAGE_SIZE, entries.size()))), page, entries.size() > PAGE_SIZE);
|
||||
}
|
||||
}
|
||||
@Override public Page list(String kind, String parent, int page) throws Exception {
|
||||
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page); c.commit(); return result; }
|
||||
}
|
||||
@Override public ThreadView thread(String id, int page) throws Exception {
|
||||
try (var c = connect()) {
|
||||
c.setAutoCommit(false); Entry entry = visible(get(c, uuid(id), false));
|
||||
if (entry.kind().equals("reply")) fail("missing");
|
||||
var result = new ThreadView(entry, list(c, "reply", id, page)); c.commit(); return result;
|
||||
}
|
||||
}
|
||||
@Override public Entry change(Actor actor, Change change) throws Exception {
|
||||
try (var c = connect()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Entry result = Community.apply(new Transaction() {
|
||||
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
|
||||
public void save(Entry e, boolean insert) throws Exception {
|
||||
if (insert) {
|
||||
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at,photo) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt(), e.photo().isEmpty()?null:e.photo())) { s.executeUpdate(); }
|
||||
} else {
|
||||
try (var s = query(c, "UPDATE sanctuary_community_entries SET photo=?,title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
|
||||
e.photo().isEmpty()?null:e.photo(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
|
||||
if (s.executeUpdate() != 1) fail("conflict");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, actor, change, clock.millis());
|
||||
c.commit(); return result;
|
||||
} catch (Exception error) { c.rollback(); throw error; }
|
||||
}
|
||||
}
|
||||
@Override public void link(Actor actor, String code) throws Exception {
|
||||
if (!code.matches("[0-9a-f]{32}")) fail("invalid");
|
||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(code.getBytes(StandardCharsets.US_ASCII)));
|
||||
try (var c = connect()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
long userId;
|
||||
try (var s = query(c, "SELECT user_id,expires_at FROM sanctuary_community_links WHERE server_id=? AND token_hash=? FOR UPDATE", serverId, hash); var r = s.executeQuery()) {
|
||||
if (!r.next() || r.getLong("expires_at") <= clock.millis()) { fail("link_expired"); return; }
|
||||
userId = r.getLong("user_id");
|
||||
}
|
||||
try (var s = query(c, "SELECT player_id FROM sanctuary_community_identities WHERE server_id=? AND (player_id=? OR user_id=?) FOR UPDATE", serverId, actor.id(), userId); var r = s.executeQuery()) { if (r.next()) fail("already_linked"); }
|
||||
try (var s = query(c, "INSERT INTO sanctuary_community_identities (server_id,player_id,user_id,player_name) VALUES (?,?,?,?)", serverId, actor.id(), userId, actor.name())) { s.executeUpdate(); }
|
||||
try (var s = query(c, "DELETE FROM sanctuary_community_links WHERE server_id=? AND token_hash=?", serverId, hash)) { s.executeUpdate(); }
|
||||
c.commit();
|
||||
} catch (Exception error) { c.rollback(); throw error; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import fr.koka.sanctuary.client.PauseAtlasAccess;
|
||||
import fr.koka.sanctuary.client.PauseAtlasWidget;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import fr.koka.sanctuary.client.CommunityPausePanels;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.gui.screens.PauseScreen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(PauseScreen.class)
|
||||
public abstract class CommunityPauseMixin extends Screen implements PauseAtlasAccess {
|
||||
@Unique private CommunityPausePanels sanctuary$community;
|
||||
protected CommunityPauseMixin(Component title) { super(title); }
|
||||
@Inject(method="init",at=@At("TAIL"))
|
||||
private void sanctuary$communityInit(CallbackInfo callback) {
|
||||
if(sanctuary$community!=null)sanctuary$community.close();
|
||||
sanctuary$community=null;
|
||||
if (((PauseScreen)(Object)this).showsPauseMenu() && ProgressionClient.active()) {
|
||||
var nativeButtons=children().stream().filter(Button.class::isInstance).map(Button.class::cast).toList();
|
||||
clearWidgets();
|
||||
sanctuary$community=new CommunityPausePanels(this,this::addRenderableWidget,nativeButtons);
|
||||
}
|
||||
}
|
||||
@Inject(method="tick",at=@At("TAIL"))
|
||||
private void sanctuary$communityTick(CallbackInfo callback) { if(sanctuary$community!=null)sanctuary$community.tick(); }
|
||||
@Override public PauseAtlasWidget sanctuary$pauseMap() {return sanctuary$community==null?null:sanctuary$community.map();}
|
||||
@Inject(method="removed",at=@At("HEAD"))
|
||||
private void sanctuary$communityRemoved(CallbackInfo callback) {if(sanctuary$community!=null)sanctuary$community.close();}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import fr.koka.sanctuary.progression.ProgressEffects;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
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;
|
||||
|
||||
/** Respawn creates a new player/menu; extend it before the next inventory packet, not next tick. */
|
||||
@Mixin(ClientPacketListener.class)
|
||||
abstract class InventoryRespawnMixin {
|
||||
@Inject(method = "handleRespawn", at = @At("RETURN"))
|
||||
private void sanctuary$restoreInventoryLayout(CallbackInfo ci) {
|
||||
var player = Minecraft.getInstance().player;
|
||||
if (player != null && ProgressionClient.active()) {
|
||||
ProgressEffects.bind(player, ProgressionClient.progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.atlas.AtlasService;
|
||||
import net.minecraft.client.server.IntegratedServer;
|
||||
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;
|
||||
|
||||
/** Reading an existing survey must work without unpausing a single-player world. */
|
||||
@Mixin(IntegratedServer.class)
|
||||
public abstract class PauseAtlasServerMixin {
|
||||
@Inject(method="tickPaused",at=@At("TAIL"))
|
||||
private void sanctuary$sendPausedAtlas(CallbackInfo callback) {AtlasService.streamPaused((IntegratedServer)(Object)this);}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
import com.llamalad7.mixinextras.sugar.Local;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import fr.koka.sanctuary.client.ArenaClient;
|
||||
import fr.koka.sanctuary.client.CycleClient;
|
||||
import fr.koka.sanctuary.cycle.CycleService;
|
||||
import fr.koka.sanctuary.arena.Arenas;
|
||||
import fr.koka.sanctuary.progression.ProgressionService;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.layouts.GridLayout;
|
||||
import net.minecraft.client.gui.layouts.LayoutElement;
|
||||
import net.minecraft.client.gui.layouts.LayoutSettings;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.PauseScreen;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
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(PauseScreen.class)
|
||||
public abstract class ProgressionPauseMixin {
|
||||
@Inject(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/LinearLayout;horizontal()Lnet/minecraft/client/gui/layouts/LinearLayout;"))
|
||||
private void sanctuary$blocodex(CallbackInfo callback,@Local GridLayout.RowHelper helper) {
|
||||
if (!ProgressionClient.active()) return;
|
||||
Screen parent=(Screen)(Object)this;
|
||||
for (String target:new String[]{"blocks","progress","history","identity"})
|
||||
helper.addChild(Button.builder(ProgressionService.label(target),
|
||||
b -> ProgressionClient.open(parent,target)).width(98).build());
|
||||
helper.addChild(Button.builder(CycleService.label("faction"),
|
||||
b -> CycleClient.open(parent,"faction")).width(98).build());
|
||||
helper.addChild(Button.builder(Arenas.text("title"),
|
||||
b -> ArenaClient.open(parent)).width(98).build());
|
||||
}
|
||||
|
||||
// These native destinations now live inside Discovery and Progression.
|
||||
@WrapOperation(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/GridLayout$RowHelper;addChild(Lnet/minecraft/client/gui/layouts/LayoutElement;)Lnet/minecraft/client/gui/layouts/LayoutElement;"))
|
||||
private LayoutElement sanctuary$groupNativePages(GridLayout.RowHelper helper,LayoutElement child,Operation<LayoutElement> original) {
|
||||
if (ProgressionClient.active() && child instanceof Button button
|
||||
&& button.getMessage().getContents() instanceof TranslatableContents text
|
||||
&& (text.getKey().equals("gui.advancements") || text.getKey().equals("gui.stats"))) return child;
|
||||
return original.call(helper,child);
|
||||
}
|
||||
|
||||
// Keep native actions and server additions; omit the feedback/social icon row entirely.
|
||||
@WrapOperation(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/GridLayout$RowHelper;addChild(Lnet/minecraft/client/gui/layouts/LayoutElement;ILnet/minecraft/client/gui/layouts/LayoutSettings;)Lnet/minecraft/client/gui/layouts/LayoutElement;"))
|
||||
private LayoutElement sanctuary$omitIcons(GridLayout.RowHelper helper,LayoutElement child,int columns,LayoutSettings settings,Operation<LayoutElement> original) {
|
||||
if (ProgressionClient.active() && child instanceof LinearLayout) return child;
|
||||
return original.call(helper,child,columns,settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
|
||||
import fr.koka.sanctuary.client.shader.SolarColour;
|
||||
import fr.koka.sanctuary.client.shader.VanillaLight;
|
||||
import net.minecraft.client.renderer.SkyRenderer;
|
||||
import net.minecraft.client.renderer.state.level.SkyRenderState;
|
||||
import org.joml.Vector3f;
|
||||
import org.joml.Vector4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Unique;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(SkyRenderer.class)
|
||||
abstract class SolarColourMixin {
|
||||
@Unique private Vector3f sanctuary$sunTint=new Vector3f(1);
|
||||
@Inject(method="render",at=@At("HEAD"))
|
||||
private void sanctuary$nativeSunset(GpuBufferSlice fog,SkyRenderState sky,CallbackInfo ci){
|
||||
sanctuary$sunTint=VanillaLight.active()?SolarColour.tint(sky):new Vector3f(1);
|
||||
}
|
||||
@ModifyArg(method="renderSun",at=@At(value="INVOKE",target="Lnet/minecraft/client/renderer/DynamicGpuData;writeTransform(Lorg/joml/Matrix4f;Lorg/joml/Vector4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"),index=1)
|
||||
private Vector4f sanctuary$tintSun(Vector4f nativeColour){
|
||||
return new Vector4f(nativeColour.x*sanctuary$sunTint.x,nativeColour.y*sanctuary$sunTint.y,nativeColour.z*sanctuary$sunTint.z,nativeColour.w);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"sanctuary.shaders.ssr_intensity_value": "Reflection strength: %s%%",
|
||||
"sanctuary.shaders.ssr_intensity_help": "Strength of visible landscape reflections on solids and water. Default: 20%.",
|
||||
"sanctuary.shaders.ssr": "Landscape reflections — experimental",
|
||||
"sanctuary.shaders.ssr": "SSR",
|
||||
"sanctuary.shaders.ssr_help": "Colours material texels with the visible landscape. Requires PBR; off-screen objects cannot be reflected.",
|
||||
"sanctuary.shaders.ssr_intensity": "Reflection strength",
|
||||
"sanctuary.graphics.default_vulkan": "Vulkan (default)",
|
||||
@@ -2303,7 +2303,7 @@
|
||||
"sanctuary.shaders.pbr": "PBR · material relief",
|
||||
"sanctuary.shaders.pbr_help": "Experimental: procedural block normals and roughness, sun, moon and local light highlights. Enable Colored lights for local direction. Range 32 blocks. ON at 80% by default.",
|
||||
"sanctuary.shaders.pbr_intensity": "PBR intensity",
|
||||
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights, including water. Default: 80%.",
|
||||
"sanctuary.shaders.pbr_intensity_help": "Material relief and highlights, including water. Default: 50%.",
|
||||
"sanctuary.shaders.pbr_intensity_value": "PBR intensity: %s %%",
|
||||
"sanctuary.shaders.lens_flare": "Lens flare · soft rectangles",
|
||||
"sanctuary.shaders.lens_flare_help": "Soft rectangular sun and moon reflections. Obstacles hide the sources. Independent of the bloom halo.",
|
||||
@@ -2330,10 +2330,80 @@
|
||||
"sanctuary.shaders.water_intensity": "Water intensity",
|
||||
"sanctuary.shaders.water_intensity_help": "At 0%, keeps native rendering. At 100%, applies all three profiles.",
|
||||
"sanctuary.shaders.water_intensity_value": "Water intensity: %s%%",
|
||||
"sanctuary.shaders.pbr_distance": "PBR and SSR distance",
|
||||
"sanctuary.shaders.pbr_distance_value": "PBR and SSR distance: %s blocks",
|
||||
"sanctuary.shaders.pbr_distance_help": "Shared range for relief and reflecting surfaces. 16, 32, 64 or 128 blocks. Longer distances cost more; mesh memory is bounded.",
|
||||
"sanctuary.shaders.pbr_distance": "PBR distance",
|
||||
"sanctuary.shaders.pbr_distance_value": "PBR distance: %s blocks",
|
||||
"sanctuary.shaders.pbr_distance_help": "Independent PBR range: 16, 32, 64, 128 or 256 blocks. Default: 64 blocks. Limited to visible terrain and mesh budget.",
|
||||
"sanctuary.shaders.water_blend": "Water blending",
|
||||
"sanctuary.shaders.water_blend_value": "Water blending: %s × %s blocks",
|
||||
"sanctuary.shaders.water_blend_help": "Transition width between bed-dependent water colours: 1, 3, 5, 9 or 17 blocks. Default: 9 × 9."
|
||||
"sanctuary.shaders.water_blend_help": "Transition width between bed-dependent water colours: 1, 3, 5, 9 or 17 blocks. Default: 9 × 9.",
|
||||
"sanctuary.shaders.ssr_distance": "SSR distance",
|
||||
"sanctuary.shaders.ssr_distance_value": "SSR distance: %s blocks",
|
||||
"sanctuary.shaders.ssr_distance_help": "Independent SSR range: 16, 32, 64, 128 or 256 blocks. Default: 64 blocks. Limited to visible terrain and mesh budget.",
|
||||
"sanctuary.community.article": "Gazette",
|
||||
"sanctuary.community.notice": "Notice board",
|
||||
"sanctuary.community.bulletin": "Server notices",
|
||||
"sanctuary.community.publish": "Publish",
|
||||
"sanctuary.community.reply": "Reply",
|
||||
"sanctuary.community.replies": "Replies",
|
||||
"sanctuary.community.edit": "Edit",
|
||||
"sanctuary.community.hide": "Hide",
|
||||
"sanctuary.community.hide_confirm": "Hide this content and its discussion?",
|
||||
"sanctuary.community.close": "Close notice",
|
||||
"sanctuary.community.reopen": "Reopen",
|
||||
"sanctuary.community.pin": "Pin",
|
||||
"sanctuary.community.unpin": "Unpin",
|
||||
"sanctuary.community.previous": "Previous",
|
||||
"sanctuary.community.next": "Next",
|
||||
"sanctuary.community.page": "Page %s",
|
||||
"sanctuary.community.back": "Back",
|
||||
"sanctuary.community.back_draft": "Back · keep draft",
|
||||
"sanctuary.community.refresh": "Refresh",
|
||||
"sanctuary.community.save": "Save",
|
||||
"sanctuary.community.saved": "Saved on the server.",
|
||||
"sanctuary.community.loading": "Loading…",
|
||||
"sanctuary.community.empty": "No posts yet.",
|
||||
"sanctuary.community.cached": "Cached copy",
|
||||
"sanctuary.community.bulletin_empty": "No server notice.",
|
||||
"sanctuary.community.category.info": "Information",
|
||||
"sanctuary.community.category.work": "Project",
|
||||
"sanctuary.community.category.need": "Request",
|
||||
"sanctuary.community.category.event": "Event",
|
||||
"sanctuary.community.open": "Open",
|
||||
"sanctuary.community.closed": "Closed · replies disabled",
|
||||
"sanctuary.community.title_label": "Title (120 characters)",
|
||||
"sanctuary.community.body_label": "Text",
|
||||
"sanctuary.community.body_hint": "Write your message…",
|
||||
"sanctuary.community.link": "Link my web account",
|
||||
"sanctuary.community.link_help": "Create a code on the website, then paste it here. The server must use online authentication.",
|
||||
"sanctuary.community.code": "Website code",
|
||||
"sanctuary.community.linked": "Account linked. You can now post on the website.",
|
||||
"sanctuary.community.forbidden": "You cannot perform this action.",
|
||||
"sanctuary.community.invalid": "Check the fields and their length.",
|
||||
"sanctuary.community.conflict": "This content has changed. Refresh; your draft is preserved.",
|
||||
"sanctuary.community.missing": "This content is missing or hidden.",
|
||||
"sanctuary.community.unavailable": "Service unavailable. Sending is not confirmed; your draft is preserved.",
|
||||
"sanctuary.community.busy": "Service busy. Try again shortly.",
|
||||
"sanctuary.community.schema": "Incompatible database version. Contact an administrator.",
|
||||
"sanctuary.community.database_only": "Website linking requires database mode.",
|
||||
"sanctuary.community.online_required": "Linking requires a server with Minecraft authentication.",
|
||||
"sanctuary.community.link_expired": "Code invalid, expired or already used.",
|
||||
"sanctuary.community.already_linked": "This player or account is already linked.",
|
||||
"sanctuary.community.actions": "⋯",
|
||||
"sanctuary.community.actions_for": "Actions · %s",
|
||||
"sanctuary.community.reply_hint": "Write a reply…",
|
||||
"sanctuary.pause.map": "World map",
|
||||
"sanctuary.pause.map_hint": "Open the map and explore",
|
||||
"sanctuary.community.more": "Load more",
|
||||
"sanctuary.community.browse": "All publications",
|
||||
"sanctuary.community.photo": "Article photo",
|
||||
"sanctuary.community.photo_choose": "Choose a screenshot…",
|
||||
"sanctuary.community.photo_change": "Change photo…",
|
||||
"sanctuary.community.photo_assign": "Assign to article",
|
||||
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
|
||||
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
|
||||
"sanctuary.community.photo_uploading": "Uploading photo…",
|
||||
"sanctuary.community.screenshots": "Screenshots",
|
||||
"sanctuary.community.screenshots_back": "Back to screenshots",
|
||||
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
|
||||
"sanctuary.community.screenshots_error": "Could not read screenshots."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"sanctuary.shaders.ssr_intensity_value": "Intensité des reflets : %s %%",
|
||||
"sanctuary.shaders.ssr_intensity_help": "Intensité des reflets du paysage visible, sur les solides et l’eau. Défaut : 20 %.",
|
||||
"sanctuary.shaders.ssr": "Reflets du paysage — expérimental",
|
||||
"sanctuary.shaders.ssr": "SSR",
|
||||
"sanctuary.shaders.ssr_help": "Colore les pixels de matière avec le paysage visible. PBR requis ; les objets hors écran ne sont pas réfléchis.",
|
||||
"sanctuary.shaders.ssr_intensity": "Intensité des reflets",
|
||||
"sanctuary.graphics.default_vulkan": "Vulkan (par défaut)",
|
||||
@@ -2303,7 +2303,7 @@
|
||||
"sanctuary.shaders.pbr": "PBR · relief des matériaux",
|
||||
"sanctuary.shaders.pbr_help": "Expérimental : normales procédurales et rugosité des blocs, reflets du soleil, de la lune et des lumières locales. Activer Lumières colorées pour la direction locale. Portée 32 blocs. Activé par défaut à 80 %.",
|
||||
"sanctuary.shaders.pbr_intensity": "Intensité PBR",
|
||||
"sanctuary.shaders.pbr_intensity_help": "Relief et éclats des matières, eau comprise. Défaut : 80 %.",
|
||||
"sanctuary.shaders.pbr_intensity_help": "Relief et éclats des matières, eau comprise. Défaut : 50 %.",
|
||||
"sanctuary.shaders.pbr_intensity_value": "Intensité PBR : %s %%",
|
||||
"sanctuary.shaders.lens_flare": "Lens flare · rectangles doux",
|
||||
"sanctuary.shaders.lens_flare_help": "Reflets rectangulaires du soleil et de la lune, aux bords dégradés. Les obstacles masquent les sources. Indépendant du halo de bloom.",
|
||||
@@ -2330,10 +2330,80 @@
|
||||
"sanctuary.shaders.water_intensity": "Intensité de l’eau",
|
||||
"sanctuary.shaders.water_intensity_help": "À 0 %, conserve le rendu natif. À 100 %, applique les trois profils.",
|
||||
"sanctuary.shaders.water_intensity_value": "Intensité de l’eau: %s%%",
|
||||
"sanctuary.shaders.pbr_distance": "Distance PBR et SSR",
|
||||
"sanctuary.shaders.pbr_distance_value": "Distance PBR et SSR : %s blocs",
|
||||
"sanctuary.shaders.pbr_distance_help": "Portée commune du relief et des surfaces réfléchissantes. 16, 32, 64 ou 128 blocs. Les distances élevées coûtent davantage ; budget de maillage limité.",
|
||||
"sanctuary.shaders.pbr_distance": "Distance PBR",
|
||||
"sanctuary.shaders.pbr_distance_value": "Distance PBR: %s blocs",
|
||||
"sanctuary.shaders.pbr_distance_help": "Portée indépendante du PBR : 16, 32, 64, 128 ou 256 blocs. Défaut : 64 blocs. Limitée au terrain visible et au budget de maillage.",
|
||||
"sanctuary.shaders.water_blend": "Mélange des eaux",
|
||||
"sanctuary.shaders.water_blend_value": "Mélange des eaux : %s × %s blocs",
|
||||
"sanctuary.shaders.water_blend_help": "Étendue de la transition des teintes selon le fond : 1, 3, 5, 9 ou 17 blocs. Défaut : 9 × 9."
|
||||
"sanctuary.shaders.water_blend_help": "Étendue de la transition des teintes selon le fond : 1, 3, 5, 9 ou 17 blocs. Défaut : 9 × 9.",
|
||||
"sanctuary.shaders.ssr_distance": "Distance SSR",
|
||||
"sanctuary.shaders.ssr_distance_value": "Distance SSR: %s blocs",
|
||||
"sanctuary.shaders.ssr_distance_help": "Portée indépendante du SSR : 16, 32, 64, 128 ou 256 blocs. Défaut : 64 blocs. Limitée au terrain visible et au budget de maillage.",
|
||||
"sanctuary.community.article": "Gazette",
|
||||
"sanctuary.community.notice": "Tableau",
|
||||
"sanctuary.community.bulletin": "Intendance",
|
||||
"sanctuary.community.publish": "Publier",
|
||||
"sanctuary.community.reply": "Répondre",
|
||||
"sanctuary.community.replies": "Réponses",
|
||||
"sanctuary.community.edit": "Modifier",
|
||||
"sanctuary.community.hide": "Masquer",
|
||||
"sanctuary.community.hide_confirm": "Masquer ce contenu et sa discussion ?",
|
||||
"sanctuary.community.close": "Clôturer",
|
||||
"sanctuary.community.reopen": "Réouvrir",
|
||||
"sanctuary.community.pin": "Épingler",
|
||||
"sanctuary.community.unpin": "Désépingler",
|
||||
"sanctuary.community.previous": "Précédent",
|
||||
"sanctuary.community.next": "Suivant",
|
||||
"sanctuary.community.page": "Page %s",
|
||||
"sanctuary.community.back": "Retour",
|
||||
"sanctuary.community.back_draft": "Retour · garder le brouillon",
|
||||
"sanctuary.community.refresh": "Actualiser",
|
||||
"sanctuary.community.save": "Enregistrer",
|
||||
"sanctuary.community.saved": "Enregistré sur le serveur.",
|
||||
"sanctuary.community.loading": "Chargement…",
|
||||
"sanctuary.community.empty": "Aucune publication.",
|
||||
"sanctuary.community.cached": "Copie en cache",
|
||||
"sanctuary.community.bulletin_empty": "Aucun message officiel.",
|
||||
"sanctuary.community.category.info": "Information",
|
||||
"sanctuary.community.category.work": "Chantier",
|
||||
"sanctuary.community.category.need": "Besoin",
|
||||
"sanctuary.community.category.event": "Rendez-vous",
|
||||
"sanctuary.community.open": "Ouvert",
|
||||
"sanctuary.community.closed": "Clôturé · réponses désactivées",
|
||||
"sanctuary.community.title_label": "Titre (120 caractères)",
|
||||
"sanctuary.community.body_label": "Texte",
|
||||
"sanctuary.community.body_hint": "Écrivez votre message…",
|
||||
"sanctuary.community.link": "Lier mon compte Web",
|
||||
"sanctuary.community.link_help": "Créez un code sur le site, puis collez-le ici. Le serveur doit être en mode authentifié.",
|
||||
"sanctuary.community.code": "Code du site",
|
||||
"sanctuary.community.linked": "Compte lié. Vous pouvez publier sur le site.",
|
||||
"sanctuary.community.forbidden": "Vous ne pouvez pas effectuer cette action.",
|
||||
"sanctuary.community.invalid": "Vérifiez les champs et leur longueur.",
|
||||
"sanctuary.community.conflict": "Le contenu a changé. Actualisez ; votre brouillon est conservé.",
|
||||
"sanctuary.community.missing": "Ce contenu est absent ou masqué.",
|
||||
"sanctuary.community.unavailable": "Service indisponible. Aucun envoi confirmé ; votre brouillon est conservé.",
|
||||
"sanctuary.community.busy": "Service occupé. Réessayez dans un instant.",
|
||||
"sanctuary.community.schema": "Version de base incompatible. Contactez un administrateur.",
|
||||
"sanctuary.community.database_only": "La liaison au site nécessite le mode base de données.",
|
||||
"sanctuary.community.online_required": "La liaison nécessite un serveur avec authentification Minecraft.",
|
||||
"sanctuary.community.link_expired": "Code incorrect, expiré ou déjà utilisé.",
|
||||
"sanctuary.community.already_linked": "Ce joueur ou ce compte est déjà lié.",
|
||||
"sanctuary.community.actions": "⋯",
|
||||
"sanctuary.community.actions_for": "Actions · %s",
|
||||
"sanctuary.community.reply_hint": "Écrire une réponse…",
|
||||
"sanctuary.pause.map": "Carte du monde",
|
||||
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
|
||||
"sanctuary.community.more": "Afficher la suite",
|
||||
"sanctuary.community.browse": "Toutes les publications",
|
||||
"sanctuary.community.photo": "Photo de l’article",
|
||||
"sanctuary.community.photo_choose": "Choisir une capture…",
|
||||
"sanctuary.community.photo_change": "Changer la photo…",
|
||||
"sanctuary.community.photo_assign": "Assigner à l’article",
|
||||
"sanctuary.community.photo_required": "Choisissez une photo pour publier cet article.",
|
||||
"sanctuary.community.photo_invalid": "Photo illisible ou trop grande.",
|
||||
"sanctuary.community.photo_uploading": "Envoi de la photo…",
|
||||
"sanctuary.community.screenshots": "Captures d’écran",
|
||||
"sanctuary.community.screenshots_back": "Retour aux captures",
|
||||
"sanctuary.community.screenshots_empty": "Aucune capture. Prenez une photo en jeu avec F2, puis revenez ici.",
|
||||
"sanctuary.community.screenshots_error": "Impossible de lire les captures."
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ layout(std140) uniform PbrScene {
|
||||
vec4 PbrParams;
|
||||
vec4 PbrFog;
|
||||
vec4 PbrEnvironment;
|
||||
vec4 PbrSunColour;
|
||||
vec4 PbrWaterOrigin;
|
||||
};
|
||||
layout(std140) uniform PixelShadows {
|
||||
mat4 InverseViewProjection;
|
||||
@@ -102,6 +104,8 @@ void main(){
|
||||
// Derivatives precede divergent discard; UV-space tangents preserve rotated/mirrored block textures.
|
||||
vec3 dx=dFdx(relativePosition),dy=dFdy(relativePosition);
|
||||
vec2 tx=dFdx(blockUv),ty=dFdy(blockUv);
|
||||
vec2 atlasSize=vec2(textureSize(BlockAtlas,0));
|
||||
float texelFootprint=max(length(tx*atlasSize),length(ty*atlasSize));
|
||||
float det=tx.x*ty.y-tx.y*ty.x;
|
||||
vec3 baseNormal=normalize(cross(dx,dy));if(dot(baseNormal,-relativePosition)<0.0)baseNormal=-baseNormal;
|
||||
// Exact receiver depth is tested by the GPU; final scene depth also masks entities.
|
||||
@@ -119,6 +123,14 @@ void main(){
|
||||
// RG stores signed height derivatives; B roughness, A metalness, not transparency.
|
||||
float intensity=PbrParams.x;
|
||||
vec2 slope=(material.rg*255.0-128.0)/127.0*intensity*(family>1.5?.24:2.0);
|
||||
// Slight world-anchored variation avoids identical crests on every water block.
|
||||
float waterVariation=1.0;
|
||||
if(family>1.5){
|
||||
uvec3 cell=uvec3(floor(mod(relativePosition+PbrWaterOrigin.xyz,256.0)*16.0+.001));
|
||||
uint seed=cell.x*1597334677u^cell.y*3812015801u^cell.z*2798796415u;
|
||||
seed=(seed^(seed>>16u))*2246822519u;seed^=seed>>13u;
|
||||
waterVariation=.75+.25*float(seed&65535u)/65535.0;
|
||||
}
|
||||
vec3 n=normalize(baseNormal+tangent*slope.x+bitangent*slope.y);
|
||||
// Reject degenerate/interpolated geometry without replacing the native scene with NaNs.
|
||||
if(any(isnan(n))||any(isinf(n))||dot(relativePosition,relativePosition)<.000001)discard;
|
||||
@@ -128,9 +140,12 @@ void main(){
|
||||
// A broad solar lobe gives water an extended glitter path at grazing angles.
|
||||
// Local lamps retain the material's original narrow lobe.
|
||||
vec4 solarMaterial=material;
|
||||
solarMaterial.b=max(material.b,family>1.5?.34:.24);
|
||||
solarMaterial.b=max(material.b,family>1.5?.42:.24);
|
||||
vec3 specular=localShine(n,v,l,solarMaterial,albedo);
|
||||
float solarRadiance=family>1.5?20.0:4.0;
|
||||
// PbrEnvironment.z identifies sunlight: moonlight keeps its modest base radiance.
|
||||
float solarRadiance=family<.5?1.0:mix(1.0,family>1.5?6.0:4.0,PbrEnvironment.z);
|
||||
// Same native sunset tint as the sun disc, converted to linear light.
|
||||
vec3 sunColour=PbrSunColour.rgb;
|
||||
float fog=max(clamp((max(length(relativePosition.xz),abs(relativePosition.y))-PbrFog.x)/max(PbrFog.y-PbrFog.x,.001),0.0,1.0),
|
||||
clamp((length(relativePosition)-PbrFog.z)/max(PbrFog.w-PbrFog.z,.001),0.0,1.0));
|
||||
float amount=PbrSun.w*pow(tint.a,8.0)*visibility(relativePosition,baseNormal)*(1.0-fog*PbrEnvironment.x)
|
||||
@@ -146,9 +161,36 @@ void main(){
|
||||
float localRelief=family<.5?(max(dot(n,arrival.xyz),0.0)-max(dot(baseNormal,arrival.xyz),0.0))*.7:0.0;
|
||||
lit=clamp(lit*(1.0+localRelief*localAmount),0.0,1.0);
|
||||
}
|
||||
// Preserve bright solar highlights instead of capping them at a medium grey.
|
||||
vec3 shine=min((specular*amount*solarRadiance+localShine(n,v,arrival.xyz,material,albedo)*localAmount)*intensity*5.0,vec3(8.0));
|
||||
lit+=(1.0-lit)*(1.0-exp(-shine));
|
||||
// Restore the pre-water response for opaque blocks. Water/glass retain their own highlights.
|
||||
// Fully matte wood, soil, foliage and fibres receive normal relief, not a broad white specular veil.
|
||||
// Keep normal relief independent from the additive specular response.
|
||||
float fullyMatte=smoothstep(.9,.98,material.b)*(1.0-material.a);
|
||||
float shineWeight=family<.5?1.0-fullyMatte:1.0;
|
||||
float shineGain=family<.5?3.0:5.0;
|
||||
vec3 skyShine=min(specular*amount*solarRadiance*intensity*shineGain*shineWeight,vec3(8.0));
|
||||
vec3 lampShine=min(localShine(n,v,arrival.xyz,material,albedo)*localAmount*intensity*shineGain*shineWeight,vec3(8.0));
|
||||
if(family<.5){
|
||||
// Share the original 0.5 budget across sun and lamps, preserving the sunset tint.
|
||||
vec3 total=skyShine+lampShine;
|
||||
vec3 scale=min(vec3(1.0),vec3(.5)/max(total,vec3(.00001)));
|
||||
skyShine*=scale;lampShine*=scale;
|
||||
}
|
||||
if(family>1.5&&PbrEnvironment.z>.5){
|
||||
// Soft-light/screen blend per native texel: broad daylight lifts the water
|
||||
// without replacing its colour and detail with a saturated white cone.
|
||||
// Glints follow animated wave facets reflecting the sun towards the eye.
|
||||
// Their pulse comes from the native wave animation, not an unrelated timer.
|
||||
vec3 halfSum=l+v;
|
||||
float alignment=pow(max(dot(n,halfSum*inversesqrt(max(dot(halfSum,halfSum),.00001))),0.0),384.0);
|
||||
float crest=smoothstep(.025,.18,length((material.rg*255.0-128.0)/127.0));
|
||||
float detail=1.0-smoothstep(.75,2.0,texelFootprint);
|
||||
float glint=alignment*crest*waterVariation*detail;
|
||||
vec3 glintShine=skyShine*(1.0+glint*1.2);
|
||||
vec3 weight=glintShine/(1.0+glintShine);
|
||||
vec3 highlight=max(lit,mix(sunColour,sqrt(lit)*sunColour,mix(.55,.30,glint)));
|
||||
lit=mix(lit,highlight,weight);
|
||||
}else lit+=(1.0-lit)*(1.0-exp(-skyShine*sunColour));
|
||||
lit+=(1.0-lit)*(1.0-exp(-lampShine));
|
||||
if(any(isnan(lit))||any(isinf(lit)))discard;
|
||||
fragColor=vec4(sqrt(clamp(lit,0.0,1.0)),1.0);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ layout(location=2) in vec2 maskUv;
|
||||
layout(location=3) in vec4 tint;
|
||||
void main(){
|
||||
vec2 dx=dFdx(blockUv),dy=dFdy(blockUv);
|
||||
if(length(relativePosition)>=ReflectionParams.w)discard;
|
||||
if(length(relativePosition)>=ReflectionLimits.z)discard;
|
||||
// Minecraft's world projection uses reverse depth: zero is the far plane.
|
||||
float opaque=texelFetch(OpaqueDepth,ivec2(gl_FragCoord.xy),0).r;
|
||||
if(gl_FragCoord.z+0.0000001<opaque)discard;
|
||||
// Native and material transforms differ slightly in evaluation order. A fixed
|
||||
// depth-buffer epsilon becomes too strict near the camera (reverse-Z).
|
||||
// Compare world distances with the same bounded margin as the colour passes;
|
||||
// the receiver attachment still selects the nearest material with exact depth.
|
||||
vec2 uv=gl_FragCoord.xy/vec2(textureSize(OpaqueDepth,0));
|
||||
vec4 visible=ReflectionInverseVP*vec4(uv*2.0-1.0,ReflectionParams.x>.5?opaque:opaque*2.0-1.0,1);
|
||||
if(opaque>0.0&&length(visible.xyz/visible.w)+.005<length(relativePosition))discard;
|
||||
if(maskUv.y<2.0&&textureGrad(BlockAtlas,blockUv,dx,dy).a<.99)discard;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Stop both applications and back up the database first. Apply once to schema v1.
|
||||
ALTER TABLE sanctuary_community_entries ADD COLUMN photo MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NULL;
|
||||
UPDATE sanctuary_community_schema SET version=2 WHERE id=1 AND version=1;
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE sanctuary_community_schema (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
INSERT INTO sanctuary_community_schema (id,version) VALUES (1,1);
|
||||
CREATE TABLE sanctuary_community_entries (
|
||||
server_id CHAR(36) NOT NULL,
|
||||
id CHAR(36) NOT NULL,
|
||||
parent_id CHAR(36) NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
author_id CHAR(36) NOT NULL,
|
||||
author_name VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (server_id,id),
|
||||
INDEX community_feed (server_id,kind,status,pinned,created_at,id),
|
||||
INDEX community_replies (server_id,parent_id,status,created_at,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
CREATE TABLE sanctuary_community_identities (
|
||||
server_id CHAR(36) NOT NULL,
|
||||
player_id CHAR(36) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
player_name VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (server_id,player_id),
|
||||
UNIQUE KEY community_user (server_id,user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
CREATE TABLE sanctuary_community_links (
|
||||
server_id CHAR(36) NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (server_id,token_hash),
|
||||
UNIQUE KEY community_pending_user (server_id,user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,502 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
@@ -204,7 +204,6 @@
|
||||
"client.MobNameRendererMixin",
|
||||
"client.LivingMobNameRendererMixin",
|
||||
"client.ProgressionHudMixin",
|
||||
"client.ProgressionPauseMixin",
|
||||
"client.DiscoveryStatsMixin",
|
||||
"client.NotificationOptionsMixin",
|
||||
"client.InventoryHotbarVisualMixin",
|
||||
@@ -213,6 +212,7 @@
|
||||
"client.AbstractContainerScreenAccessor",
|
||||
"client.SlotPositionAccessor",
|
||||
"client.InventoryScreenMixin",
|
||||
"client.InventoryRespawnMixin",
|
||||
"client.InventoryEquipmentTooltipMixin",
|
||||
"client.RecipeBookScreenMixin",
|
||||
"client.RecipeBookComponentMixin",
|
||||
@@ -263,6 +263,7 @@
|
||||
"client.InventoryPointerAccess",
|
||||
"client.OperatorWorldOptionsMixin",
|
||||
"client.StarSkyMixin",
|
||||
"client.SolarColourMixin",
|
||||
"client.ProgressiveFogMixin",
|
||||
"client.ShaderSaturationMixin",
|
||||
"client.WeatherEnvironmentMixin",
|
||||
@@ -286,7 +287,9 @@
|
||||
"client.NaturalWaterFluidMixin",
|
||||
"client.NaturalWaterFogMixin",
|
||||
"client.NaturalWaterFogColorMixin",
|
||||
"client.NaturalWaterSectionCopyAccess"
|
||||
"client.NaturalWaterSectionCopyAccess",
|
||||
"client.PauseAtlasServerMixin",
|
||||
"client.CommunityPauseMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.nio.file.*;
|
||||
import java.sql.*;
|
||||
import java.util.UUID;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
/** Opt-in round trip with the Laravel CommunityInteropTest and a disposable database. */
|
||||
public final class CommunityInterop {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String url=System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC");
|
||||
Path file=Path.of(System.getenv("SANCTUARY_COMMUNITY_INTEROP_FILE"));
|
||||
String phase=args[0]; var json=new GsonBuilder().setPrettyPrinting().create();
|
||||
JsonObject fixture;
|
||||
if(phase.equals("seed")) {
|
||||
fixture=new JsonObject();
|
||||
for(String key:new String[]{"server","player","article"})fixture.addProperty(key,UUID.randomUUID().toString());
|
||||
var store=new SqlCommunityStore(url,"root","",fixture.get("server").getAsString());
|
||||
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0,CommunitySmoke.PHOTO));
|
||||
Files.writeString(file,json.toJson(fixture));
|
||||
} else {
|
||||
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
|
||||
String server=fixture.get("server").getAsString();
|
||||
var actor=new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false);
|
||||
var store=new SqlCommunityStore(url,"root","",server);
|
||||
if(phase.equals("claim")) {
|
||||
String code=fixture.get("code").getAsString();
|
||||
store.link(actor,code);
|
||||
CommunitySmoke.refuse("link_expired",()->store.link(actor,code));
|
||||
CommunitySmoke.refuse("link_expired",()->new SqlCommunityStore(url,"root","",UUID.randomUUID().toString()).link(actor,code));
|
||||
} else if(phase.equals("verify")) {
|
||||
var thread=store.thread(fixture.get("article").getAsString(),0);
|
||||
CommunitySmoke.check(thread.entry().revision()==2 && thread.entry().body().equals("Modifié depuis le site 🌳"),"Website edit visible in Java");
|
||||
CommunityPhoto.validate(store.thread(fixture.get("web_article").getAsString(),0).entry().photo());
|
||||
CommunitySmoke.check(!thread.entry().photo().isEmpty(),"Photo preserved by website edit");
|
||||
CommunitySmoke.check(thread.replies().entries().size()==1 && thread.replies().entries().getFirst().body().equals("Réponse depuis le site"),"Website reply visible in Java");
|
||||
CommunitySmoke.refuse("conflict",()->store.change(actor,new Change("edit",thread.entry().id(),"","article","info","Depuis Minecraft","Ancien brouillon",1)));
|
||||
} else throw new IllegalArgumentException(phase);
|
||||
}
|
||||
System.out.println("COMMUNITY154_INTEROP_"+phase.toUpperCase()+"_PASS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
public final class CommunitySmoke {
|
||||
static Actor owner=new Actor(UUID.randomUUID().toString(),"Auteur",false);
|
||||
static Actor other=new Actor(UUID.randomUUID().toString(),"Voisin",false);
|
||||
static Actor moderator=new Actor(UUID.randomUUID().toString(),"Admin",true);
|
||||
public static String photo() {
|
||||
try {var image=new java.awt.image.BufferedImage(960,540,java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
var random=new Random(157);for(int y=0;y<540;y++)for(int x=0;x<960;x++)image.setRGB(x,y,random.nextInt());
|
||||
return CommunityPhoto.encode(image,960,540);
|
||||
} catch(Exception e) {throw new AssertionError(e);}
|
||||
}
|
||||
static final String PHOTO=photo();
|
||||
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0,kind.equals("article")?PHOTO:null); }
|
||||
static Change action(String action,Entry e) { return new Change(action,e.id(),e.parentId(),e.kind(),e.category(),e.title(),e.body(),e.revision()); }
|
||||
static void check(boolean ok,String why) { if(!ok)throw new AssertionError(why); }
|
||||
interface Work { void run() throws Exception; }
|
||||
static void refuse(String code,Work work) throws Exception { try { work.run(); throw new AssertionError("Expected "+code); } catch(Refused e) { check(e.code.equals(code),"Expected "+code+", got "+e.code); } }
|
||||
static Entry scenario(CommunityStore store) throws Exception {
|
||||
var request=create(UUID.randomUUID().toString(),"","notice","Un pont","Du bois et des bras 🌳");
|
||||
var post=store.change(owner,request);
|
||||
check(store.change(owner,request).equals(post),"Exact retry has one ID and unchanged revision");
|
||||
refuse("conflict",()->store.change(other,request));
|
||||
refuse("forbidden",()->store.change(other,action("edit",post)));
|
||||
refuse("forbidden",()->store.change(owner,action("pin",post)));
|
||||
refuse("forbidden",()->store.change(owner,create(UUID.randomUUID().toString(),"","bulletin","Message","officiel")));
|
||||
var reply=store.change(other,create(UUID.randomUUID().toString(),post.id(),"reply","","Je viens."));
|
||||
check(store.thread(post.id(),0).replies().entries().size()==1,"Reply belongs to parent");
|
||||
refuse("invalid",()->store.change(other,create(UUID.randomUUID().toString(),reply.id(),"reply","","Nested")));
|
||||
var closed=store.change(owner,action("close",post));
|
||||
refuse("closed",()->store.change(other,create(UUID.randomUUID().toString(),post.id(),"reply","","Trop tard")));
|
||||
refuse("conflict",()->store.change(owner,action("edit",post)));
|
||||
var reopened=store.change(owner,action("reopen",closed));
|
||||
var pinned=store.change(moderator,action("pin",reopened));
|
||||
for(int i=0;i<12;i++)store.change(owner,create(UUID.randomUUID().toString(),"","notice","Autre "+i,"Texte"));
|
||||
check(store.list("notice","",0).entries().getFirst().id().equals(post.id()),"Pinned first");
|
||||
check(store.list("notice","",0).more()&&store.list("notice","",1).entries().size()==3,"Bounded stable pagination");
|
||||
store.change(moderator,action("hide",pinned));
|
||||
refuse("missing",()->store.thread(post.id(),0));
|
||||
refuse("missing",()->store.change(other,action("edit",reply)));
|
||||
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","§ccommand")));
|
||||
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","x".repeat(4001))));
|
||||
refuse("photo_required",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0)));
|
||||
refuse("photo_invalid",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0,"not an image")));
|
||||
var article=store.change(owner,create(UUID.randomUUID().toString(),"","article","🌳".repeat(120),"Récit"));
|
||||
check(store.thread(article.id(),0).entry().equals(article),"Unicode text intact");
|
||||
check(article.photo().equals(PHOTO),"Assigned photo persisted");
|
||||
check(store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,PHOTO)).equals(article),"Photo retry is idempotent");
|
||||
refuse("conflict",()->store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,"changed")));
|
||||
refuse("photo_invalid",()->CommunityPhoto.validate("A".repeat(CommunityPhoto.MAX_TEXT+1)));
|
||||
var edited=store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",1));
|
||||
check(edited.photo().equals(PHOTO),"Edit retains photo");
|
||||
refuse("photo_required",()->store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,"")));
|
||||
refuse("forbidden",()->store.change(other,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,PHOTO)));
|
||||
return edited;
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path dir=Files.createTempDirectory("sanctuary-community-check-");
|
||||
String server=UUID.randomUUID().toString();Path file=dir.resolve("community.json");
|
||||
var store=new FileCommunityStore(file,server);var article=scenario(store);
|
||||
var reopened=new FileCommunityStore(file,server);
|
||||
check(reopened.thread(article.id(),0).entry().equals(article),"Durable restart");
|
||||
Files.writeString(file,"broken");
|
||||
try { reopened.change(owner,create(UUID.randomUUID().toString(),"","article","A","B"));throw new AssertionError("Overwrite corrupt file"); }catch(java.io.IOException expected){}
|
||||
check(Files.readString(file).equals("broken"),"Corruption retained, no false acknowledgement");
|
||||
String url=System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC");
|
||||
if(url!=null){
|
||||
var sql=new SqlCommunityStore(url,"root","",server);
|
||||
var value=scenario(sql);
|
||||
check(new SqlCommunityStore(url,"root","",server).thread(value.id(),0).entry().equals(value),"Database restart");
|
||||
var isolated=new SqlCommunityStore(url,"root","",UUID.randomUUID().toString());
|
||||
check(isolated.list("notice","",0).entries().isEmpty(),"Different server isolated");
|
||||
refuse("missing",()->isolated.thread(value.id(),0));
|
||||
String expired="1".repeat(32), live="2".repeat(32), reassignment="3".repeat(32);
|
||||
try(var c=java.sql.DriverManager.getConnection(url,"root","");var q=c.prepareStatement("INSERT INTO sanctuary_community_links(server_id,token_hash,user_id,expires_at) VALUES(?,?,?,?)")) {
|
||||
for(int i=0;i<3;i++) {
|
||||
String token=List.of(expired,live,reassignment).get(i);
|
||||
q.setString(1,server);q.setString(2,java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(token.getBytes(java.nio.charset.StandardCharsets.US_ASCII))));
|
||||
q.setLong(3,100+i);q.setLong(4,i==0?0:System.currentTimeMillis()+600000);q.executeUpdate();
|
||||
}
|
||||
}
|
||||
refuse("link_expired",()->sql.link(owner,expired));
|
||||
sql.link(owner,live);
|
||||
refuse("link_expired",()->sql.link(owner,live));
|
||||
refuse("already_linked",()->sql.link(owner,reassignment));
|
||||
System.out.println("COMMUNITY154_SQL_PASS");
|
||||
}
|
||||
String operation=UUID.randomUUID().toString();
|
||||
var transfer=new CommunityPhoto.Transfer(operation,2,1000);
|
||||
transfer.append(operation,0,2,"abc",1000);
|
||||
refuse("photo_invalid",()->transfer.append(operation,0,2,"abc",1001));
|
||||
refuse("photo_required",()->transfer.value(operation,1001));
|
||||
transfer.append(operation,1,2,"def",1002);
|
||||
check(transfer.value(operation,1003).equals("abcdef"),"Ordered transfer");
|
||||
refuse("photo_required",()->transfer.value(UUID.randomUUID().toString(),1003));
|
||||
refuse("photo_required",()->transfer.value(operation,121001));
|
||||
Path legacy=dir.resolve("legacy.json");
|
||||
var root=new com.google.gson.JsonObject();root.addProperty("version",1);root.addProperty("serverId",server);
|
||||
var entries=new com.google.gson.JsonArray();var old=new com.google.gson.Gson().toJsonTree(article).getAsJsonObject();old.remove("photo");entries.add(old);root.add("entries",entries);
|
||||
String before=root.toString();Files.writeString(legacy,before);
|
||||
var migrated=new FileCommunityStore(legacy,server);
|
||||
check(migrated.thread(article.id(),0).entry().photo().isEmpty(),"Old text article readable");
|
||||
refuse("photo_required",()->migrated.change(owner,action("edit",migrated.thread(article.id(),0).entry())));
|
||||
migrated.change(owner,new Change("edit",article.id(),"","article","info",article.title(),article.body(),article.revision(),PHOTO));
|
||||
check(Files.readString(dir.resolve("legacy.json.v1.bak")).equals(before),"Exact migration backup");
|
||||
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
|
||||
System.out.println("COMMUNITY157_PHOTOS_PASS");
|
||||
System.out.println("COMMUNITY154_FILE_PASS");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.146"
|
||||
version = "beta.157"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user