Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7979f33028 | ||
|
|
460c4706d3 | ||
|
|
ad960ef9bc | ||
|
|
835e6116e6 | ||
|
|
aadff6fd5e | ||
|
|
705fe9c1a0 | ||
|
|
dbb9e3428d | ||
|
|
9a429c40a1 | ||
|
|
905b3bcfa1 | ||
|
|
0c55a37562 | ||
|
|
8deba7cfb4 | ||
|
|
c1d2278674 | ||
|
|
bdaad0c940 | ||
|
|
19aa8054a5 | ||
|
|
9791d178ab | ||
|
|
32158e2e05 | ||
|
|
3bfb3cb251 | ||
|
|
0eb1a381d4 | ||
|
|
8de569bbf1 | ||
|
|
b97ec6c9e8 | ||
|
|
448e2febff | ||
|
|
1c23d0c17e |
@@ -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,12 @@
|
||||
# Changelog
|
||||
|
||||
## beta.144 — Code d’accès à la création
|
||||
|
||||
- Hello World demande le code `SANC-XXXX-XXXX` quand le serveur a l’URL et le jeton du site.
|
||||
- Le client ne contacte pas le site. Le serveur vérifie, puis consomme le code au moment de créer l’habitant.
|
||||
- Le lien Discord est enregistré à part. Une création interrompue après `redeemed` peut se terminer sans nouveau code si le compte est encore connu.
|
||||
- [Contrat](docs/access-code-beta144.md).
|
||||
|
||||
## beta.110 — Atelier d’argile inclus
|
||||
|
||||
- Une seule livraison réunit l’atelier d’argile, les statuaires et l’import GLB de beta.106 avec les œufs et la neige saisonnière de beta.107 à beta.109.
|
||||
|
||||
@@ -1,18 +1,131 @@
|
||||
# Sanctuary
|
||||
|
||||
## beta.148 — PBR 50 % et soleil diffus (pack local)
|
||||
[Dernière release : beta.166](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.166) ·
|
||||
[Historique des publications beta.144–166](docs/publications-beta144-166.md).
|
||||
Les releases sont distinctes du canal packwiz, qui reste inchangé lors de ce rattrapage.
|
||||
|
||||
## beta.166 — inscription depuis le site
|
||||
|
||||
Accueil avec code récupéré sur le web et reprise après coupure.
|
||||
[Contrat, migration et vérifications](docs/inscription-web-beta166.md).
|
||||
|
||||
## Vérification du socle beta.165
|
||||
|
||||
Les 23 échecs historiques ont été corrigés dans les scénarios de test :
|
||||
252/252 GameTests passent, ainsi que `check build`. Code de jeu inchangé.
|
||||
[Actualisation et preuves](docs/actualisation-tests-beta165.md).
|
||||
|
||||
## beta.165 — article Gazette et conversation
|
||||
|
||||
Grande photo et texte dans un même conteneur à gauche, auteur dessous, réponses
|
||||
à droite ou sous l’article sur petit écran.
|
||||
[Résultat et vérifications](docs/gazette-scene-beta165.md) ·
|
||||
[Audit des 23 échecs connus](docs/audit-echecs-beta165.md).
|
||||
|
||||
## beta.164 — calendrier et intendance
|
||||
|
||||
Date et heure réunies à gauche ; titre serveur aligné sur la carte et les panneaux.
|
||||
[Résultat et vérifications](docs/pause-calendar-beta164.md).
|
||||
|
||||
## beta.163 — menu pause encadré
|
||||
|
||||
Calendrier, intendance et horloge en en-tête ; deux groupes de navigation,
|
||||
séparateurs natifs et reprise centrée dans le footer.
|
||||
[Résultat et vérifications](docs/pause-frame-beta163.md).
|
||||
|
||||
## beta.162 — tableau en scène et conversation
|
||||
|
||||
Portrait de l’auteur tourné vers la souris, bulle aux couleurs de l’annonce,
|
||||
demande à gauche et réponses à droite. Retour/Actualiser restent en haut ;
|
||||
la conversation passe dessous sur les GUI étroits.
|
||||
[Résultat et vérifications](docs/notice-scene-beta162.md).
|
||||
|
||||
## beta.161 — correctifs de séance et site vivant
|
||||
|
||||
Liste des joueurs sur U, suivi des demandes expliqué, intendance compacte et
|
||||
compteurs réels partagés avec le site. Site public en lecture seule, Gazette
|
||||
en accordéon avec aperçus compacts.
|
||||
[Contrat et vérifications](docs/session-fixes-beta161.md).
|
||||
|
||||
## beta.160 — laboratoire de test en duo
|
||||
|
||||
Shader désactivé sur une configuration neuve, serveur local léger, personnages
|
||||
de test optionnels et séances de captures horodatées.
|
||||
[Mode d’emploi et vérifications](docs/duo-lab-beta160.md) ·
|
||||
[Récap Discord 130–160](docs/discord-beta130-160.md).
|
||||
|
||||
## beta.159 — recherche communautaire et galerie adaptable
|
||||
|
||||
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
|
||||
année dans les dates et galerie adaptée au GUI avec grand aperçu.
|
||||
Repères « ! » et infobulles sur la carte pour les demandes suivies géolocalisées.
|
||||
[Résultat et vérifications](docs/community-search-beta159.md).
|
||||
|
||||
## beta.158 — cartes communautaires et demandes suivies
|
||||
|
||||
Grilles à deux colonnes, articles photo/texte, annonces colorées avec visages,
|
||||
coffres descriptifs et suivi persistant fichier/MariaDB. Sélecteur photo compact.
|
||||
[Contrat et vérifications](docs/community-cards-beta158.md).
|
||||
|
||||
## beta.157 — photo obligatoire dans la Gazette
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
## beta.147 — PBR proche, lune et portée 256
|
||||
|
||||
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)
|
||||
## beta.146 — Reflets et transitions de l’eau
|
||||
|
||||
Correction du masquage des personnages en F5 et sélection stricte des surfaces PBR.
|
||||
PBR à 80 % et SSR à 20 % par défaut, distance commune PBR/SSR
|
||||
@@ -2348,7 +2461,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.148 |
|
||||
| Sanctuary / pack | beta.166 |
|
||||
|
||||
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
|
||||
@@ -2377,7 +2490,7 @@ décrits dans [Validation](docs/testing.md).
|
||||
|
||||
Résultats :
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.148.jar` : mod à installer avec
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.166.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).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# beta.144 — Code d’accès à la création
|
||||
|
||||
Document historique de la PR de Chris. Le contrat de reprise ci-dessous est
|
||||
remplacé par [beta.166](inscription-web-beta166.md), notamment pour les codes
|
||||
consommés et les déconnexions.
|
||||
|
||||
Le site accepte la candidature, whitelist le pseudo et envoie un code `SANC-XXXX-XXXX`.
|
||||
Hello World demande ce code avant de créer le personnage. Le client ne parle pas
|
||||
au site. Seul le serveur appelle l’API, avec le jeton `SANCTUARY_API_TOKEN`.
|
||||
|
||||
Sans ces réglages, Hello World reste celui de beta.143 : biographie, couleur et
|
||||
familier, sans code. Dès que l’URL et le jeton sont présents, un nouveau
|
||||
personnage exige un code reconnu. Un habitant déjà enregistré entre sans écran.
|
||||
|
||||
## Réglage serveur
|
||||
|
||||
Variables d’environnement, ou fichier `config/sanctuary/access.json` si une
|
||||
variable manque :
|
||||
|
||||
```json
|
||||
{"appUrl":"https://exemple.sanctuary","token":"..."}
|
||||
```
|
||||
|
||||
`appUrl` est l’origine du site, sans barre finale. Le serveur appelle
|
||||
`POST {appUrl}/api/v1/access-codes/verify` puis `redeem`. Le jeton n’est pas
|
||||
écrit dans les logs, le client ou le pack. Une URL ou un jeton illisible ferme
|
||||
la création : aucun personnage n’est inventé hors ligne.
|
||||
|
||||
## Parcours
|
||||
|
||||
1. La whitelist laisse passer le pseudo. L’écran s’ouvre tant que l’UUID n’a pas
|
||||
d’habitant.
|
||||
2. Le joueur saisit le code. L’affichage du champ et de l’exemple utilise
|
||||
l’alphabet galactique standard (`minecraft:alt`, la police de la table
|
||||
d’enchantement). La valeur envoyée reste le texte tapé. Un format complet
|
||||
déclenche `verify`, pas chaque frappe. « Code reconnu » n’écrit rien.
|
||||
3. Confirmer envoie le code de la session, la biographie, la couleur et le
|
||||
familier. Le pseudo envoyé au site est celui de la session.
|
||||
4. `redeem` répond `redeemed` avec `discord_id` : le lien est enregistré, puis
|
||||
l’habitant est créé. Les connexions suivantes sautent l’écran.
|
||||
5. Erreur, site injoignable ou jeton refusé : le joueur reste sur Hello World.
|
||||
|
||||
Une déconnexion avant `redeemed` ne consomme pas le code. Si le site a répondu
|
||||
`redeemed` et que l’écriture de l’habitant est interrompue, le lien Discord
|
||||
reste dans `data/sanctuary-access.json` (`pending`, schéma 1, graine du monde).
|
||||
La confirmation suivante termine le personnage sans rappeler `redeem`.
|
||||
|
||||
Si le code est déjà consommé et que le site ne renvoie plus `discord_id`, le
|
||||
joueur reste bloqué avec un message de reprise. Le site doit alors renvoyer
|
||||
`minecraft_username` et `discord_id` pour le même pseudo. Le mod accepte cette
|
||||
réponse, que `valid` soit vrai ou faux, et finit la création.
|
||||
|
||||
Le registre des habitants ne change pas. Le Discord est un fichier à part.
|
||||
Un fichier illisible est conservé et refuse l’accueil.
|
||||
|
||||
## Limites
|
||||
|
||||
Le gel dans le monde n’est pas ajouté : Hello World reste avant l’entrée, comme
|
||||
aujourd’hui. Inventaire, commandes et dimensions ne sont pas accessibles tant
|
||||
que l’habitant n’existe pas. La whitelist RCON reste celle du site. Un pseudo
|
||||
ajouté à la main, sans code, voit l’écran sans pouvoir le valider.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Actualisation des tests — socle beta.165
|
||||
|
||||
Suite à l’audit des 23 échecs. Changements limités aux GameTests et à leur
|
||||
préparation ; aucun changement des règles, des données de production ou de
|
||||
format de monde. Pas de nouvelle version binaire : le laboratoire reste beta.165.
|
||||
|
||||
## Scénarios corrigés
|
||||
|
||||
- Placement : joueur à portée réelle, sans occuper la cellule visée. Les compteurs,
|
||||
événements, empreintes et les huit diamants de la tombe restent vérifiés.
|
||||
- Inventaires : données d’ouverture explicites pour l’atelier d’argile et le
|
||||
multibloc, avec maintien de la boucle sur toutes les entrées du registre.
|
||||
- Familiers : achat de l’accès puis commande serveur de mode travail ; le helper
|
||||
vérifie aussi que le mode combat n’accorde pas les anciens passifs. Les bonus,
|
||||
consommation de ressources et annulations restent vérifiés.
|
||||
- Ancienne invulnérabilité : test des coups amicaux sans dégâts, du K.-O. sur
|
||||
dégât létal et de la conservation de l’œuf. Le poisson terrestre utilise son
|
||||
profil actuel et garde sa vérification de transition aquatique.
|
||||
- Permissions : accès public à l’introduction, restrictions opérateur sur names
|
||||
et community admin. Catalogue recompte les 67 documents, 2042 recettes,
|
||||
1780 items, 1374 blocs et 1937 identifiants distincts. Hauteur actuelle 640.
|
||||
- Dragon : cycle natif tickNonPassenger (commonTick + tick), compteur d’entité
|
||||
contrôlé, déplacement/altitude/collision conservés.
|
||||
- Dalle/coffre : visée à portée du dessus réel, sans collision du joueur avec la
|
||||
surface à bâtir. Les assertions de fusion, collisions et contenu restent.
|
||||
- Hydrologie : les ouvertures OUTLET doivent être des coupes sans source ni
|
||||
sédiment, appartenant à un déversement terminal déclaré. Les autres cellules
|
||||
conservent les contrôles de support naturel.
|
||||
|
||||
## Diagnostics indépendants
|
||||
|
||||
- [Construction](audit-construction-beta165.md)
|
||||
- [Dragon](audit-dragon-beta165.md)
|
||||
- [Berges](audit-berges-beta165.md)
|
||||
|
||||
## Validation
|
||||
|
||||
Premier rejeu ciblé : **81/81 tests obligatoires passent** en 1 min 45 s.
|
||||
Journal : `build/test-refresh-focused.log`.
|
||||
Groupes : inventory, inventoryflow, companions, refonte, accessories, graves,
|
||||
collections, progression, demeure. Ce rejeu valide notamment les nouvelles
|
||||
assertions atteintes et le déplacement du dragon. Construction et hydrologie
|
||||
sont réservées au rejeu complet suivant.
|
||||
|
||||
`./gradlew check build --continue -PsanctuaryQuickTests=true` : **BUILD SUCCESSFUL**
|
||||
en 7 min 11 s. **252/252 GameTests obligatoires réussis**, zéro échec, puis
|
||||
les autres contrôles de `check` terminent avec succès.
|
||||
Journal : `build/test-refresh-full.log`.
|
||||
|
||||
Les quatre cas analysés par les agents passent dans ce rejeu, sans modification
|
||||
production. Le patch supplémentaire de parois OUTLET proposé à titre conditionnel
|
||||
n’a pas été appliqué : aucune assertion correspondante n’a échoué.
|
||||
Ce résultat porte sur la suite configurée ci-dessus ; aucune stabilité statistique
|
||||
sur plusieurs graines, plateformes ou répétitions n’est revendiquée.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Audit du support naturel des berges — beta.165
|
||||
|
||||
## Verdict
|
||||
|
||||
L’échec initial est une **attente de test antérieure aux ouvertures de berges
|
||||
alpha.30**, avec une confiance très forte. La cellule signalée n’est pas une
|
||||
terrasse sèche avec sédiments : c’est la deuxième ouverture `Kind.OUTLET` du
|
||||
plan régional. Son absence de fondation est intentionnelle. Aucune modification
|
||||
de génération n’est recommandée pour faire passer cette assertion.
|
||||
|
||||
Ce diagnostic ne démontre pas que le reste du test passe : celui-ci échoue dès
|
||||
la préparation, avant sa vérification des blocs après décoration et ticks.
|
||||
Aucun monde, chunk, fichier de production ou test partagé n’a été modifié pour
|
||||
cet audit. Aucun serveur ni JVM supplémentaire n’a été lancé.
|
||||
|
||||
## Preuves et chaîne de traitement
|
||||
|
||||
1. `build/beta165-check-build.log:962` signale, au tick 0 :
|
||||
`Cell[x=-141, z=-86, waterY=-1, bedY=238, carveTop=241, material=STONE,
|
||||
featureId=15307446929, sedimentDepth=0]`.
|
||||
2. `PopulationHydrologyRuntime.regionPlan` construit un plan classique admis,
|
||||
puis applique `BankOutlets30.openBanks` pour les réglages `unified_5/10/20`.
|
||||
La densité vient du même `finalDensity` et du même `RandomState` que le
|
||||
diagnostic, via un mémo de signe. Les coordonnées locales sont translatées
|
||||
avec l’origine régionale. Dans la région `(0,0)`, cette translation est nulle.
|
||||
3. `BankOutlets30.java:30–45` cherche le vide à 1–5 blocs d’un bassin et crée
|
||||
des cellules sèches de profondeur de sédiments zéro, classées `OUTLET`.
|
||||
Ces cellules n’ajoutent ni roche ni eau : elles décrivent la coupe de la berge.
|
||||
4. Recalcul indépendant des opérations entières 64 bits en Python, sans moteur
|
||||
Minecraft : pour graine 0 et région `(0,0)`, la graine régionale non signée vaut
|
||||
`12661893618221475390`; la base des identifiants de sorties vaut
|
||||
`15307446928`. L’identifiant en échec vaut exactement base + 1, donc la
|
||||
deuxième sortie (index 1). Ce calcul renforce l’identification par
|
||||
`sedimentDepth=0`, au lieu de la supposer depuis le nom du test.
|
||||
5. `PopulationHydrologyRuntime.apply` exclut explicitement `plan.isOutletCell`
|
||||
de la validation des deux couches de support. Sa boucle de sédiments ne fait
|
||||
aucune écriture avec profondeur zéro ; la boucle de coupe supprime les blocs
|
||||
entre `bedY+1` et `carveTop`. L’eau se propage ensuite par les ticks vanilla.
|
||||
6. `PopulationDiagnostics.checkOwnership:333–344` applique au contraire la
|
||||
densité positive à **toutes** les cellules, donc impose ici une fondation à
|
||||
Y237 et Y238. C’est précisément la contrainte absente du contrat des sorties.
|
||||
7. Le contrat publié dans `docs/generation-alpha30.md`, section Hydrologie,
|
||||
autorise une chute dans le vide et précise l’absence de fondation ou de
|
||||
colonne d’eau artificielle. `River30Smoke.java:23–24` vérifie déjà profondeur
|
||||
zéro et absence de nouvelle source. Ce smoke a passé dans le journal beta.165
|
||||
(lignes 453–454).
|
||||
|
||||
La documentation Java de `PopulationHydrology.Cell:53` décrit encore seulement
|
||||
les cellules sédimentaires : elle mérite une clarification future pour le cas
|
||||
OUTLET, mais ne prévaut pas sur le contrat alpha.30 ni son implémentation dédiée.
|
||||
|
||||
## Adaptation ciblée proposée
|
||||
|
||||
Le patch préparé dans `build/audit-berges.patch` ne change que
|
||||
`PopulationDiagnostics.checkOwnership`. Il conserve les assertions de région
|
||||
et d’appartenance de toutes les cellules. Pour une cellule **explicitement
|
||||
classée OUTLET**, il exige :
|
||||
|
||||
- aucune source d’eau, aucun sédiment, un intervalle de coupe positif ;
|
||||
- un déversement terminal déclaré avec le même identifiant.
|
||||
|
||||
Les autres cellules conservent intégralement l’assertion de densité naturelle des
|
||||
sédiments et des deux couches de support. Ne pas remplacer ce contrôle par une
|
||||
exception générale pour toutes les cellules sèches ou de profondeur zéro.
|
||||
|
||||
Le patch reste isolé et non appliqué pour intégration par l’agent principal.
|
||||
|
||||
## Reproduction et validation minimales
|
||||
|
||||
Le défaut de contrat se reproduit sans monde avec le témoin existant
|
||||
`River30Smoke` : la côte synthétique est solide à x≤4 ; la brèche atteint x=5,
|
||||
qui est du vide. Exiger un support positif sous cette cellule ferait échouer
|
||||
une ouverture intentionnelle que le smoke valide.
|
||||
|
||||
Pour confirmer le témoin réel, rejouer le GameTest
|
||||
`UnifiedWorldGameTests.retainedHydrologySurvivesNativeDecoration` après application
|
||||
ciblée, dans le **monde jetable de tests**, graine 0, population 10, diamètre 724.
|
||||
Conserver les vérifications de blocs FULL, de sédiments, de coquille et de ticks.
|
||||
La commande et l’ordonnancement seront assurés par l’agent principal pour ne pas
|
||||
saturer les 8 Go du Mac. Aucun passage de ce test n’est revendiqué ici.
|
||||
|
||||
## Points à surveiller après la première correction
|
||||
|
||||
- `verifyFinishedShell:460` impose sédiments 3–5 à ses cellules sélectionnées.
|
||||
La sélection normale porte sur lac/étang/terrasse et exclut les OUTLET ; son
|
||||
fallback prend la première feature. Si une ouverture est sélectionnée à
|
||||
l’avenir, elle nécessitera son propre contrôle de coupe, sans support imposé.
|
||||
- Le contrôle de paroi humide reconnaît `isSpillOpening`, qui ne contient que
|
||||
la position terminale. Une ouverture de trois blocs de large peut également
|
||||
créer une paroi ouverte avant ce point. Si cette assertion échoue ensuite,
|
||||
vérifier le voxel contre une cellule OUTLET déclarée et son intervalle exact
|
||||
de coupe ; ne pas désactiver le contrôle de toutes les parois.
|
||||
- Le test complet n’a pas encore atteint ses contrôles finaux avec ce patch.
|
||||
Les éventuels nouveaux échecs doivent être diagnostiqués séparément.
|
||||
- Les plafonds historiques à Y384 restent présents dans certaines inspections
|
||||
naturelles alors que la dimension atteint Y640. Ils ne causent pas l’échec
|
||||
ici à Y237/238, et ne sont pas modifiés dans ce patch ciblé.
|
||||
|
||||
## Fichiers examinés
|
||||
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/BankOutlets30.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrologyRuntime.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationIslandDensity.java`
|
||||
- `mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/IslandCapacity.java`
|
||||
- `mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java`
|
||||
- `mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/River30WorldGameTests.java`
|
||||
- `mods/sanctuary/src/test/java/fr/koka/sanctuary/worldgen/River30Smoke.java`
|
||||
|
||||
## Complément : contrôle précis de la paroi ouverte
|
||||
|
||||
Un second patch non appliqué, `build/audit-berges-shell.patch`, est disponible
|
||||
**uniquement si l’exécution atteint un échec de paroi correspondant à une
|
||||
ouverture réelle**. Il ajoute un cas au contrôle des parois de
|
||||
`verifyFinishedShell`, après ses exceptions existantes pour l’eau planifiée et
|
||||
le voxel terminal du spill.
|
||||
|
||||
Le voxel doit appartenir au plan de sa propre position X/Z, à une cellule
|
||||
explicitement `OUTLET`, sans eau planifiée ni sédiments, associée par son
|
||||
identifiant à un spill terminal. Son Y doit être **strictement supérieur à
|
||||
bedY et inférieur ou égal à carveTop**. Le bloc final doit alors être de l’air
|
||||
ou de l’eau vanilla ; un bloc solide ou de la lave fait toujours échouer le test.
|
||||
Les couches sous la coupe, les voisins latéraux hors emprise, les autres types
|
||||
de cellules et les voxels au-dessus de la coupe conservent leur contrôle normal.
|
||||
|
||||
Le patch parcourt `cellsAt` plutôt que de se fier seulement à `cellAt(x,y,z)` :
|
||||
ce dernier inclut aussi les couches de support dans sa sélection, ce qui aurait
|
||||
créé une exception trop large. Les assertions sur les sédiments sélectionnés
|
||||
restent inchangées. La suite en cours doit décider si ce patch est nécessaire ;
|
||||
aucun résultat d’exécution n’est anticipé.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Audit ciblé — construction groupée sur dalle et coffre
|
||||
|
||||
Audit du 24 septembre 2026, sources beta.165. Aucun changement de production,
|
||||
aucun monde lancé ou modifié, aucune nouvelle exécution de GameTest dans cet audit.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Les deux refus initiaux ont une explication commune de **fixture hors portée**, avec
|
||||
une confiance forte : le rayon de visée s’arrête à 3,5 blocs avant la surface réelle.
|
||||
Ils ne démontrent ni une fusion de dalle cassée, ni une altération du contenu du coffre.
|
||||
Les assertions suivantes restent à rejouer ; elles ne sont pas déclarées réussies.
|
||||
|
||||
Le contrat beta.009 utilisait la portée native, tandis que le contrat actuel
|
||||
[beta.081](build-reach-beta081.md) fixe le rang 1 à 3,5 blocs. Le même point de vue
|
||||
atteint encore un cube plein, mais pas les surfaces plus basses.
|
||||
|
||||
## Chaîne de refus
|
||||
|
||||
- `Building009GameTests.java:107–113` : plan 3×3 de dalles basses ; joueur au rang 1 ;
|
||||
`aim` place ses pieds en `(0,5 ; 1 ; −2,5)` par rapport à l’origine, puis vise
|
||||
`(0,5 ; 0,5 ; 0,5)`.
|
||||
- `Building009GameTests.java:165–170` : coffre central et pierre autour ; même position,
|
||||
mais `aim` conserve la cible d’un cube plein `(0,5 ; 1 ; 0,5)`.
|
||||
- `BuildReach.java:13–21` et `BuildingLimits.java:6` : portée 3,5 ; le contrôle préalable
|
||||
de proximité utilise l’AABB du **bloc entier**, pas la forme de la dalle/coffre.
|
||||
- `BuildSelection.java:40–44` : `player.pick(3.5, 1, false)` doit réellement toucher
|
||||
le bloc d’origine. Un MISS ou un autre bloc donne `null`.
|
||||
- `BuildingSession.java:26–30` : ce `null` empêche de créer la session, avant tout
|
||||
placement, fusion ou usage du coffre.
|
||||
- Le journal existant `build/beta165-check-build.log:1105–1138` confirme les refus
|
||||
à `start`, lignes 113 et 170 des tests, au tick 0.
|
||||
|
||||
## Géométrie vérifiée
|
||||
|
||||
Les constantes natives ont été inspectées par `javap -c -p` dans le JAR local exact
|
||||
Minecraft 26.3 : `Avatar` définit les yeux debout à 1,62 ; `SlabBlock` utilise
|
||||
`column(16,0,8)` pour la dalle basse ; `ChestBlock` utilise `column(14,0,14)` pour
|
||||
le coffre simple. Traces dans `build/audit-construction-avatar-bytecode.txt` et
|
||||
`build/audit-construction-native-bytecode.txt`. Aucun serveur/JVM Minecraft lancé.
|
||||
|
||||
Les yeux sont donc en **E=(0,5 ; 2,62 ; −2,5)**. Calculs Python indépendants du jeu :
|
||||
|
||||
| Surface et visée actuelle | Premier impact géométrique prévu | Distance yeux-impact |
|
||||
|---|---|---:|
|
||||
| Cube plein témoin | `(0,5 ; 1 ; 0,5)` | 3,409457 |
|
||||
| Dalle basse, visée corrigée sur son dessus | `(0,5 ; 0,5 ; 0,5)` | **3,673472** |
|
||||
| Coffre, visée restée au dessus d’un cube plein | `(0,5 ; 0,875 ; 0,731481)` | **3,672533** |
|
||||
|
||||
Le coffre occupe horizontalement `[1/16 ; 15/16]`, donc ce point est bien dans son
|
||||
dessus. Les cubes de pierre voisins ne coupent pas ce rayon avant lui : à `y=1`,
|
||||
le rayon est déjà au centre de la cellule d’origine. Les autres dalles basses ne
|
||||
coupent pas davantage le rayon avant `y=0,5`. Le précontrôle AABB entier passe,
|
||||
avec une distance minimale de 2,978993 : il ne garantit pas que le rayon atteigne
|
||||
la forme réelle.
|
||||
|
||||
L’écart d’environ 0,173 bloc dépasse largement les arrondis float de la direction
|
||||
et de la hauteur des yeux. Ces calculs expliquent le refus dans les deux fixtures ;
|
||||
une trace native reste utile pour confirmer explicitement le MISS en exécution.
|
||||
|
||||
## Correction recommandée, limitée aux tests
|
||||
|
||||
Patch proposé, **non appliqué** : `build/audit-construction.patch`.
|
||||
|
||||
Le helper dédié `aimInsetTop` rapproche les pieds à `z=−1,5`, conserve `y=1` et
|
||||
vise le centre du dessus réel (`y=0,5` pour la dalle, `14/16` pour le coffre).
|
||||
Les distances deviennent respectivement **2,914515** et **2,654247** blocs.
|
||||
Le joueur reste hors du plan 3×3 (son bord proche est vers `z=−1,2`, le plan
|
||||
commence à `z=−1`) afin de ne pas exclure une pose par sa propre collision.
|
||||
Une assertion explicite vérifie que le rayon atteint la face UP avant le démarrage.
|
||||
|
||||
Toutes les assertions métier existantes restent : neuf consommations/fusion,
|
||||
dalles de plafond, exclusion de la vache, trois diamants conservés, menu fermé,
|
||||
refus des objets à placement particulier. Aucun changement de portée de production,
|
||||
aucune suppression de test, aucun passage en créatif pour masquer le problème.
|
||||
|
||||
## Protocole ciblé restant
|
||||
|
||||
1. Relire/appliquer le patch de tests sur une branche de correction coordonnée.
|
||||
2. Rejouer la famille `building` dans un monde GameTest jetable, avec le filtre
|
||||
du dépôt `-PsanctuaryFocusedTests=building`, après libération de la mémoire du
|
||||
laboratoire. Ne pas exécuter en parallèle plusieurs serveurs sur le Mac 8 Go.
|
||||
3. Si un refus subsiste, tracer yeux, portée, `player.pick(...)`, bloc/face/distance,
|
||||
`BuildSelection.aimed`, puis nombre de cibles : ne pas augmenter arbitrairement
|
||||
la portée ou neutraliser une validation.
|
||||
4. Confirmer les assertions suivantes : une fois la première barrière levée, des
|
||||
défauts secondaires peuvent devenir visibles. Garder un scénario séparé de refus
|
||||
hors portée à la limite, sans le confondre avec fusion ou conservation d’objets.
|
||||
|
||||
Risque du patch faible et limité à la géométrie de test. Risque de modifier la
|
||||
production maintenant inutilement élevé : cela modifierait le contrat de portée
|
||||
pour contourner un scénario préparé avec l’ancien contrat.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Audit dragon — beta.165
|
||||
|
||||
## Verdict
|
||||
|
||||
L’échec de `Companion019GameTests.creatureProfilesActuallyMove` est expliqué par
|
||||
une simulation incomplète du cycle natif de l’entité. Il ne démontre pas un
|
||||
blocage du dragon en jeu. Aucune correction de locomotion ne se justifie avant
|
||||
le rejeu du scénario avec son horloge d’entité effective.
|
||||
|
||||
## Chaîne causale confirmée
|
||||
|
||||
1. [Le test](../mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java)
|
||||
appelle `pet.tick()` 120 fois immédiatement, au tick GameTest 0. Le journal
|
||||
`build/beta165-check-build.log` confirme l’assertion de déplacement échouée au tick 0.
|
||||
2. Dans Minecraft **26.3**, `ServerLevel.tickNonPassenger(Entity)` appelle
|
||||
**`Entity.commonTick()` puis `Entity.tick()`**. L’incrément `tickCount++` se trouve
|
||||
dans `commonTick`, avec la mise à jour des anciennes positions et du délai
|
||||
d’invulnérabilité ; il ne se trouve pas dans `Entity.tick()` ou `baseTick()`.
|
||||
Vérification directe du bytecode du JAR natif local par `javap -c -p` :
|
||||
`build/audit-dragon-serverlevel.txt` (méthode `tickNonPassenger`),
|
||||
`build/audit-dragon-entity.txt` (méthode `commonTick`).
|
||||
3. Au premier tick, [FamiliarEntity.configureMotion](../mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java)
|
||||
choisit `FlyingPathNavigation`, `FlyingMoveControl`, désactive la gravité et
|
||||
appelle `stopRoaming()`.
|
||||
4. [FamiliarRoam.reset](../mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarRoam.java)
|
||||
fixe `nextChoice = pet.tickCount + 20`. L’errance lit **pet.tickCount**, pas
|
||||
`level.getGameTime()`. Les 120 appels directs laissent donc `now = 0` et
|
||||
`nextChoice = 20`. La condition `if(now < nextChoice) return` interdit toute
|
||||
première destination.
|
||||
5. Le familier apparaît déjà près du joueur, à environ +2 blocs en Y grâce à
|
||||
`teleportNearOwner`. Il n’atteint pas le seuil de retour vers le propriétaire
|
||||
qui pourrait contourner cette attente. Aucun déplacement vers une destination
|
||||
n’est donc engagé dans ce scénario.
|
||||
|
||||
L’hypothèse initiale d’une simple dépendance au temps du monde était trop vague :
|
||||
la cause directe est l’absence de **commonTick**, et donc le compteur d’entité figé.
|
||||
|
||||
## Correction de test recommandée
|
||||
|
||||
Pour ce test synchrone de locomotion, remplacer les appels de simulation par
|
||||
`h.getLevel().tickNonPassenger(pet)` ; cela reproduit le préambule natif complet,
|
||||
contrairement à un simple `pet.tickCount++`. Conserver les assertions sur le profil,
|
||||
le déplacement supérieur à un bloc, l’altitude et l’absence de collision.
|
||||
Ajouter une assertion du nombre de ticks réellement avancés afin d’éviter une
|
||||
régression de la fixture. Rejouer les vérifications Ghast et Wither, masquées
|
||||
jusqu’ici par l’échec dragon.
|
||||
|
||||
Le correctif proposé est disponible dans `build/audit-dragon.patch`, sans avoir
|
||||
été appliqué aux sources par cet audit. Cette boucle reste synchrone : le temps
|
||||
du monde, les hooks serveur et le propriétaire ne progressent pas. Elle vérifie
|
||||
le cycle de locomotion de l’entité, pas une séance complète de suivi en jeu.
|
||||
|
||||
Un scénario supplémentaire de suivi en ticks réels est utile si ce premier rejeu
|
||||
échoue : propriétaire déplacé sans dépasser le seuil de téléportation, personnage
|
||||
et graine d’errance fixés, trajet effectivement parcouru contrôlé. Il doit vivre
|
||||
dans un monde de test jetable, jamais dans la sauvegarde du laboratoire.
|
||||
|
||||
## Aléa et limites
|
||||
|
||||
Le tempérament provient du UUID du lien, créé aléatoirement. Les rayons d’errance
|
||||
sont 2,5 / 4 / 5 blocs et les pauses 100–139 / 25–64 / 50–89 ticks. Ces pauses
|
||||
sont intentionnelles : ne pas imposer un mouvement à chaque tick. Pour une
|
||||
reproduction déterministe, fixer la graine du générateur de l’entité et le
|
||||
tempérament dans les données du test, ou tester explicitement les trois.
|
||||
|
||||
Les destinations nécessitent des chunks chargés, une autorisation de mouvement,
|
||||
un volume libre et un chemin accessible. Aucun de ces refus n’a été démontré
|
||||
ici : l’ancien scénario s’arrêtait **avant** leur évaluation.
|
||||
|
||||
Audit statique et lecture du bytecode terminés. À ce stade, aucun serveur de test
|
||||
supplémentaire lancé, aucune source de jeu modifiée, aucune sauvegarde touchée.
|
||||
Le passage effectif du test corrigé reste à confirmer dans l’exécution coordonnée.
|
||||
|
||||
## Résultat de l’intégration
|
||||
|
||||
Le patch proposé a ensuite été intégré aux tests par l’agent principal.
|
||||
Le rejeu complet `build/test-refresh-full.log` termine avec 252/252 GameTests
|
||||
réussis et `check build` réussi. Aucun code de production ni monde existant modifié.
|
||||
Voir [la validation consolidée](actualisation-tests-beta165.md).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Audit des 23 échecs GameTest — socle beta.165
|
||||
|
||||
Audit initial du 24 septembre 2026, code `dbb9e34` (tag beta.165).
|
||||
|
||||
Suite : [actualisation des tests et validation](actualisation-tests-beta165.md).
|
||||
|
||||
## Portée et méthode
|
||||
|
||||
Lecture des 23 messages du dernier journal `build/beta165-check-build.log`, des
|
||||
méthodes de test et des chemins de production concernés. La liste a été comparée
|
||||
à beta.164 : mêmes 23 identifiants. Recompte local des JSON de collections.
|
||||
Aucun test désactivé, aucune correction de code, aucune modification du monde,
|
||||
aucun nouveau lancement Minecraft pendant cet audit. Le laboratoire reste disponible.
|
||||
|
||||
Il s’agit d’un diagnostic statique étayé par l’exécution précédente, pas d’une
|
||||
preuve que les tests passeront après adaptation. Un échec à la première assertion
|
||||
masque potentiellement des problèmes dans la suite de la même méthode.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**19 échecs ont une explication étayée dans le scénario de test ou un ancien
|
||||
contrat ; 4 nécessitent encore une reproduction ciblée.** Ce ne sont donc pas
|
||||
23 bugs joueurs démontrés. Inversement, la stabilité de la liste ne prouve pas
|
||||
l’absence de bugs. Les objectifs des tests restent majoritairement pertinents.
|
||||
|
||||
| Famille | Nombre | Lecture principale |
|
||||
| --- | ---: | --- |
|
||||
| Placement simple / empreinte / tombe | 5 | Positions de test hors portée |
|
||||
| Menus d’inventaire | 3 | Constructeur incompatible avec les menus étendus |
|
||||
| Bonus de familiers | 6 | Mode travail absent des fixtures |
|
||||
| Invulnérabilité et poisson familier | 2 | Anciennes règles remplacées par le système de combat |
|
||||
| Collections, permissions, hauteur du monde | 3 | Attentes historiques à remettre à jour |
|
||||
| Construction dalle/coffre | 2 | Raycast et portée à instrumenter |
|
||||
| Déplacement du dragon | 1 | Ticks réels et navigation à reproduire |
|
||||
| Hydrologie | 1 | Contrat de support naturel à examiner |
|
||||
|
||||
## Inventaire exhaustif
|
||||
|
||||
| Test | Verdict | Preuve et limite | Suite pertinente |
|
||||
| --- | --- | --- | --- |
|
||||
| [BlockKnowledgeGameTests.placementCountsConfirmedStatesAndRejectsFailure](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/BlockKnowledgeGameTests.java:44) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [BlockKnowledgeGameTests.listenersReceiveUpdatedProgressionAndStockChanges](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/BlockKnowledgeGameTests.java:220) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [MaterialActivityGameTests.nativeActionsProduceDatedDeltasWhileObservationsProduceNone](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/MaterialActivityGameTests.java:39) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [Demeure006GameTests.confirmedGesturesAndPersonalAtlasBoundary](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Demeure006GameTests.java:20) | Test mal positionné — forte confiance | Joueur en (5,5 ; 3 ; 5,5), support en (1 ; 1 ; 1) : la distance horizontale seule dépasse 5,65 blocs. Le mixin de placement applique la portée réelle (vanilla ou progression), avant les assertions métier. | Rapprocher le joueur et viser la face réelle ; conserver les assertions de compteurs, événements et empreinte. Ajouter/conserver un cas hors portée refusé. |
|
||||
| [GravesFood038GameTests.ordinaryPlacementAndCreativeBreakPreserveGrave](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/GravesFood038GameTests.java:120) | Test mal positionné — forte confiance | Joueur novice à 3 blocs horizontalement du clic, yeux au-dessus : distance supérieure aux 3 blocs de portée initiale. Le test échoue avant la conservation des composants et la casse créative. | Remettre le clic à portée, puis vérifier impérativement les 8 diamants après pose et casse ; le résultat actuel ne prouve aucune perte d’objets. |
|
||||
| [Inventory010GameTests.machineQuickMovesAndMounts](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory010GameTests.java:77) | Initialisation de test incompatible — certaine | Boucle sur tout BuiltInRegistries.MENU et appel create(id, inventory). Des menus Sanctuary sont ExtendedMenuType et exigent des données supplémentaires ; exception avant les comparaisons. | Séparer menus vanilla et menus étendus ; fournir les données requises aux menus Sanctuary, garder les tests de conservation et de shift-clic. |
|
||||
| [Inventory010GameTests.everyNativeContainerKeepsItsIndices](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory010GameTests.java:58) | Initialisation de test incompatible — certaine | Boucle sur tout BuiltInRegistries.MENU et appel create(id, inventory). Des menus Sanctuary sont ExtendedMenuType et exigent des données supplémentaires ; exception avant les comparaisons. | Séparer menus vanilla et menus étendus ; fournir les données requises aux menus Sanctuary, garder les tests de conservation et de shift-clic. |
|
||||
| [Inventory012GameTests.nativeAndExtraRowsHaveIdenticalMachinePriorities](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Inventory012GameTests.java:38) | Initialisation de test incompatible — certaine | Même boucle sur tous les menus et même constructeur sans données réseau. | Même adaptation commune ; comparer les destinations et reliquats des lignes natives et supplémentaires. |
|
||||
| [Companion019GameTests.switchingEggRemovesPassiveAndActive](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:45) | Contrat du scénario périmé — forte confiance | L’attribut de chute du chat n’est pas actif dans le mode de combat par défaut. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.poisonDurationAndMilkUseNativeHooks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:53) | Contrat du scénario périmé — forte confiance | La réduction de poison est attendue sans sélectionner le mode travail. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.cropCyclesStopAfterOwnerLeaves](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:152) | Contrat du scénario périmé — forte confiance | La préparation BEE n’active pas le mode travail nécessaire au bonus de culture. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion019GameTests.furnaceBonusConsumesFuelAndKeepsOneOutput](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:163) | Contrat du scénario périmé — forte confiance | La préparation BLAZE n’active pas le mode travail nécessaire au bonus de cuisson. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Companion032GameTests.axolotlFoodDurationAndGolemKnockbackUseNativeHooks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion032GameTests.java:77) | Contrat du scénario périmé — forte confiance | Le bonus de durée de consommation est attendu hors du mode autorisant les anciens passifs. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [GravesFood038GameTests.familiarSaturationPreviewUsesActualServerPassive](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/GravesFood038GameTests.java:130) | Contrat du scénario périmé — forte confiance | L’assertion passive(p)==species échoue avant de comparer l’aperçu et la consommation. Le helper equip règle l’attunement, mais FamiliarBattle.prepare initialise workMode=false ; CompanionService.passive renvoie alors null. | Préparer explicitement le mode travail par le chemin prévu, puis tester aussi l’absence de bonus en mode combat. Rejouer le reste du scénario : ses assertions suivantes restent non validées. |
|
||||
| [Accessory018GameTests.familiarIsHarmlessAndEscapesWalls](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Accessory018GameTests.java:82) | Ancien contrat invulnérable — forte confiance | Le test exige que genericKill ne cause aucun dégât. hurtServer délègue désormais à FamiliarBattle.hurt ; le système possède santé et K.-O. depuis beta.054. | Remplacer l’invulnérabilité universelle par les règles actuelles : dégâts autorisés, coups amicaux, K.-O., œuf préservé. Conserver les vérifications de collisions, sortie de mur et non-duplication. |
|
||||
| [Companion019GameTests.aquaticPetFlopsAndThenSwims](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:113) | Ancien contrat de déplacement — forte confiance | Le test exige une vitesse ≤ 0,06 sur terre. configureMotion réserve le mode poisson ralenti aux familiers aquatiques sans profil de combat actif ; sinon la vitesse vient du profil. | Tester séparément le profil actuel sur terre et la transition dans l’eau. Ne pas rétablir automatiquement l’ancien poisson ralenti. |
|
||||
| [Companion019GameTests.creatureProfilesActuallyMove](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Companion019GameTests.java:104) | À isoler — navigation | Le profil aérien est reconnu ; c’est le déplacement > 1 et l’altitude > propriétaire + 0,5 qui échouent. Le test appelle pet.tick 120 fois sans faire avancer normalement le monde ; l’errance actuelle dépend de destinations sûres, du pathfinding et comporte des pauses. | Reproduire dans un monde de test avec ticks réels, graine/personnalité fixées, propriétaire déplacé, cible et chemin enregistrés. Si l’immobilité persiste, corriger la navigation. |
|
||||
| [Collections035GameTests.exhaustiveCatalogueMatchesLoadedVanilla](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Collections035GameTests.java:17) | Constantes périmées — certaine | Le dernier comptage attend 1658 items / 1286 blocs / 1815 identifiants distincts. Recompte des 67 JSON : 1780 / 1374 / 1937. Les 2042 recettes uniques sont toujours présentes ; le test a déjà passé les contrôles précédents des identifiants et recettes. | Comparer la couverture aux registres et au contrat de collections ; documenter les nombres actuels, éviter qu’un total historique soit l’unique preuve d’exhaustivité. |
|
||||
| [Progression003GameTests.serverCustomNameConfiguration](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Progression003GameTests.java:120) | Permission testée au mauvais niveau — certaine | Le test exige que toute la racine /sanctuary soit inaccessible aux joueurs. intro et community link sont maintenant publics ; names et community admin portent leur propre condition opérateur. | Tester un joueur ordinaire et un opérateur sur chaque sous-commande sensible. Vérifier names reload en particulier ; ne pas rebloquer toute la racine. |
|
||||
| [UnifiedWorldGameTests.populationTerrainAndNaturalSpawnRemainPresent](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldGameTests.java:56) | Hauteur historique périmée — certaine | Attend minY=0 et hauteur=384. La dimension Sanctuary actuelle déclare hauteur et logical_height=640, minY=0 ; le test client de création attend lui aussi 640. | Actualiser le contrat de hauteur, puis rejouer les assertions suivantes sur le spawn naturel. Ne pas modifier la dimension ni les mondes existants. |
|
||||
| [Building009GameTests.slabMergingCeilingAndEntityCollisions](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Building009GameTests.java:107) | À isoler — construction groupée | Échec au premier start (ligne 113), avant fusion des dalles. Le fixture vise le centre d’une dalle basse depuis une position conçue pour un cube plein ; la portée au rang 1 est 3,5 et le point visé sur la dalle est plus éloigné. | Journaliser hit réel, bloc touché, distance, portée et raison de refus ; reproduire à portée courte puis à la limite. Conserver fusion, plafond et exclusion des entités. |
|
||||
| [Building009GameTests.containerSupportsAndUnsupportedItems](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/Building009GameTests.java:165) | À isoler — construction groupée | Échec au premier start sur coffre (ligne 170), avant toute vérification du contenu. Le coffre n’a pas la collision d’un cube plein ; le raycast peut toucher le support voisin ou dépasser la portée. | Même diagnostic que la dalle ; vérifier que le coffre ne s’ouvre pas et garde ses 3 diamants. Ne pas assimiler l’échec à une corruption d’inventaire. |
|
||||
| [UnifiedWorldGameTests.retainedHydrologySurvivesNativeDecoration](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/UnifiedWorldGameTests.java:39) | À isoler — génération, priorité haute | Échec dans PopulationDiagnostics.checkOwnership sur la densité naturelle du support à x=-141, z=-86, bedY=238, sedimentDepth=0, waterY=-1 (terrasse sèche). Ce contrôle survient avant la vérification finale des blocs décorés ; son nom ne prouve donc pas une fuite d’eau en jeu. | Comparer l’admission de cette cellule et la densité réellement utilisée, puis les blocs finaux dans un monde jetable, graine 0 / 10 joueurs / diamètre 724. Le contrat Cell annonce un support naturel : ne pas supprimer cette assertion sans explication. |
|
||||
|
||||
## Priorités proposées
|
||||
|
||||
1. **Remettre les tests en mesure de vérifier leur sujet** : placements à portée,
|
||||
construction correcte des menus, sous-commandes protégées, inventaire de
|
||||
collections et hauteur 640. Changements de tests ciblés, sans affaiblir leurs
|
||||
vérifications métier. Les tombes et transferts d’inventaire sont prioritaires
|
||||
parce qu’ils protègent les objets des joueurs.
|
||||
2. **Isoler dalle/coffre et hydrologie** : les deux premiers concernent une action
|
||||
courante ; le dernier touche la génération et exige un monde jetable, jamais
|
||||
une régénération de la sauvegarde de test actuelle. Aucun changement de
|
||||
génération n’est autorisé implicitement par cet audit.
|
||||
3. **Actualiser les scénarios familiers** selon le mode travail/combat, puis
|
||||
reproduire le vol du dragon avec une horloge de monde réelle. Garder les
|
||||
contrôles d’anti-duplication, de ressources consommées et d’annulation.
|
||||
4. Rejouer les familles corrigées, puis la suite complète. Toute nouvelle
|
||||
assertion atteinte doit être réévaluée ; ne pas annoncer « 19 réglés » avant cela.
|
||||
|
||||
La correction des fixtures et constantes paraît contenue. Les quatre cas à
|
||||
reproduire ne permettent pas encore une estimation fiable. Aucune suppression de
|
||||
fonctionnalité ni suppression de test n’est recommandée à ce stade.
|
||||
|
||||
## Points d’entrée dans le code
|
||||
|
||||
- [BlockPlacementReachMixin](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/BlockPlacementReachMixin.java:14)
|
||||
- [BuildingLimits](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/building/BuildingLimits.java:6)
|
||||
- [CompanionService](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/companion/CompanionService.java:71)
|
||||
- [FamiliarBattle](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarBattle.java:125)
|
||||
- [FamiliarEntity](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java:262)
|
||||
- [FamiliarRoam](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/familiar/FamiliarRoam.java:16)
|
||||
- [Statuary](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java:39)
|
||||
- [Multiblocks](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/multiblock/Multiblocks.java:36)
|
||||
- [ProgressionService](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/progression/ProgressionService.java:178)
|
||||
- [PopulationDiagnostics](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/gametest/java/fr/koka/sanctuary/gametest/PopulationDiagnostics.java:333)
|
||||
- [PopulationHydrology](/Users/koka/Documents/sanctuary/sanctuary-community-beta154/mods/sanctuary/src/main/java/fr/koka/sanctuary/worldgen/PopulationHydrology.java:56)
|
||||
|
||||
Contrat fonctionnel des familiers : [beta.054](familiar-combat-beta054.md).
|
||||
@@ -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,98 @@
|
||||
# beta.158 — cartes communautaires et demandes suivies
|
||||
|
||||
## Contrat v3 et migration
|
||||
|
||||
Les règles de génération et les inventaires Minecraft ne changent pas. Les coffres
|
||||
sont des sélections descriptives : aucun objet n'est prélevé, réservé, livré ou
|
||||
créé. Les échanges et la répartition des récompenses sont organisés entre joueurs.
|
||||
Il n'y a ni gagnant automatique ni validation automatique d'une livraison.
|
||||
|
||||
Une annonce peut porter `task` : position facultative (dimension, x/y/z), liste
|
||||
`materials` de 27 identifiants d'objets et quantités (1 à 999999), participation
|
||||
`shared` ou `single`, récompense `none`, `items` (27 sélections) ou `custom` (240
|
||||
caractères). Les catégories info/work/need/event gardent leurs identifiants.
|
||||
Le serveur Minecraft valide aussi les objets et dimensions contre son registre.
|
||||
Les données du site sont descriptives et restent lisibles si un objet manque côté
|
||||
client ; les identifiants inconnus ne créent jamais d'objet.
|
||||
|
||||
Les abonnements sont authentifiés, idempotents, au plus 16 par joueur et 64 par
|
||||
annonce. Une annonce single accepte un seul abonné. L'auteur ne s'abonne pas à sa
|
||||
propre annonce. Fermeture et masquage retirent le suivi actif ; réouvrir autorise
|
||||
à nouveau l'abonnement. Les abonnements existants ne sont pas supprimés par la
|
||||
fermeture : un joueur peut se désabonner. Changer shared vers single est refusé
|
||||
si plusieurs abonnés sont présents. La liste côté HUD est issue du serveur ; les
|
||||
notifications affichent les trois suivis ouverts les plus récents, avec le lieu
|
||||
et un rappel des matériaux. La fermeture est annoncée, sans attribuer de gain.
|
||||
|
||||
Fichier : ajout du champ task et de la collection followers ; lecture v1/v2 sans
|
||||
écriture. À la première écriture, backup exact `.v1.bak` ou `.v2.bak` sans
|
||||
écrasement puis écriture atomique en v3. Le plafond 64 Mio reste appliqué. Aucun
|
||||
monde personnel n'est migré pendant le développement. Retour arrière : serveur
|
||||
arrêté, restauration du backup et de la version logicielle correspondante.
|
||||
|
||||
MariaDB : arrêter les écritures du mod et du site, sauvegarder, appliquer la
|
||||
migration v2→v3 (SQL livré ou Laravel), puis mettre à jour les deux applications.
|
||||
Colonne task nullable, tables followers et subscribers (verrous par joueur).
|
||||
Aucune migration automatique par le mod ; aucune suppression des anciens posts.
|
||||
Les anciens articles et annonces restent lisibles. Rollback uniquement par
|
||||
restauration explicite ; pas de suppression automatique des nouvelles données.
|
||||
|
||||
La Gazette et le tableau utilisent une grille à deux colonnes. Les aperçus de
|
||||
photo sont bornés à 6 Kio binaires par carte ; les listes ne diffusent pas les
|
||||
photos complètes. L'intendance reste affichée dans l'en-tête, sans accès public
|
||||
à son historique ni à son éditeur ; sa gestion est réservée au panneau admin.
|
||||
Le panneau Web est complété par `/sanctuary community admin` côté jeu, réservé
|
||||
aux opérateurs par le serveur, notamment pour les installations en mode fichier.
|
||||
Les métadonnées de l’article (visage, nom, date) sont sur la rangée du bouton
|
||||
« … », juste sous l’encadré photo/texte. La galerie est plafonnée à 420 unités
|
||||
de largeur et 180 de hauteur ; l’aperçu du formulaire est limité à 160 × 72.
|
||||
Le bouton de liaison du compte Web disparaît des écrans communautaires en jeu.
|
||||
Les sondages restent une piste, sans fonctionnalité livrée dans cette version.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Vérifications ciblées du 23 septembre 2026 :
|
||||
|
||||
- Stockage fichier et MariaDB : métadonnées, inscription exclusive concurrente,
|
||||
idempotence, limite de 16 suivis, fermeture/réouverture, désabonnement,
|
||||
refus shared→single occupé, masquage et redémarrage. Migration v1/v2 avec
|
||||
conservation exacte des backups.
|
||||
- Client natif Vulkan : français/anglais, échelles GUI 2/3/4, grilles de deux
|
||||
colonnes, défilement et chargement des cartes suivantes sans retour en haut,
|
||||
galerie compacte, photo obligatoire, publication, coffre de sélection,
|
||||
position courante, récompense personnalisée, abonnement et HUD.
|
||||
- Vérification du déplacement visage/nom/date sous l’article, à côté de « … ».
|
||||
- Accès opérateur au panneau d’intendance en jeu : refus au joueur ordinaire,
|
||||
ouverture et publication après activation des permissions natives.
|
||||
- Régression du menu pause : colonnes, boutons, carte, défilements indépendants,
|
||||
longs articles/annonces et réponse, sous Vulkan en FR/EN et GUI 2/3/4.
|
||||
- Site : 23 tests / 131 assertions, dont droits, migrations v3 sans perte ni
|
||||
rétrogradation, validation des matériaux et inscription exclusive.
|
||||
- Interopérabilité MariaDB réelle Java→PHP→Java : photo, édition, réponse,
|
||||
identité vérifiée, demande et abonnement Web relus par le mod (18 assertions PHP).
|
||||
- Ressources de shaders : 29 fichiers identiques au JAR beta.157, qui conservait
|
||||
le socle beta.151. Les anciens JAR et MRpack sont conservés.
|
||||
|
||||
Suite générale : `./gradlew check build assemblePack` exécuté ; 252 GameTests,
|
||||
229 réussites et les mêmes 23 échecs identifiés dans la beta.157, aucun nouvel ID.
|
||||
Comparaison conservée dans `build/cards158-server-failures.json`. Les autres
|
||||
contrôles, le build et l’assemblage passent avec cette suite déjà vérifiée exclue :
|
||||
`./gradlew check build assemblePack :sanctuary:runClientGameTest -x :sanctuary:runGameTest`
|
||||
avec les propriétés client Cards158/Vulkan et la base MariaDB de développement.
|
||||
Le dernier parcours confirme aussi le refus des quantités invalides et la
|
||||
conservation des 17 bûches et 3 diamants réels après publication descriptive.
|
||||
|
||||
Export packwiz réussi ; version interne beta.158 et JAR embarqué vérifiés.
|
||||
Captures finales : `build/cards158-database-screens/` ; captures fichier :
|
||||
`build/cards158-file-screens/`. Les grilles, la galerie et le pied d’article
|
||||
ont été inspectés visuellement. Journaux : `build/beta158-*.log`.
|
||||
|
||||
Site local : commit `0dcab21` sur
|
||||
`codex/community-quests`. Aucun déploiement dans une installation personnelle,
|
||||
aucune publication du canal packwiz ni modification d’un monde existant.
|
||||
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.158.jar` — SHA-256 `0590320dcece7f94543ab17e99fb23ac37cc3e2578499aa6e80ad801212675e8`.
|
||||
- `build/Sanctuary-beta.158.mrpack` — SHA-256 `9c1e556ddce3094febf92631c8a1c8cc0ec56971b332cd9c2d7448f2f9931b89`.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Communauté Sanctuary — contrat v1
|
||||
|
||||
> Depuis beta.158, le schéma actif est la v3 : photos et demandes suivies.
|
||||
> Appliquer les migrations avant de connecter les deux applications.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Évolution v3 — demandes suivies (beta.158)
|
||||
|
||||
Annonce : `task` contient `location` nullable (`dimension`, `x`, `y`, `z`),
|
||||
`materials` et `rewards` (27 objets maximum, `item` namespacé de 100 caractères
|
||||
maximum, `count` de 1 à 999999), `audience` shared/single, `reward` none/items/custom
|
||||
et `customReward` (240 caractères). Les coffres sont descriptifs : échanges
|
||||
et partage des récompenses entre joueurs, sans transaction d'inventaire.
|
||||
|
||||
Les tables `sanctuary_community_followers` et `sanctuary_community_subscribers`
|
||||
portent les abonnements et les verrous par joueur. Maximum 16 abonnements/joueur,
|
||||
64/annonce, un seul en mode single ; pas d'auto-abonnement de l'auteur.
|
||||
Le masquage supprime les abonnements ; la fermeture les conserve mais suspend
|
||||
le suivi actif. Un passage en single est refusé au-delà d'un abonné.
|
||||
|
||||
Arrêter les écritures et sauvegarder avant migration. Installation SQL neuve :
|
||||
schema-v1.sql, schema-v1-to-v2.sql, puis schema-v2-to-v3.sql du mod. Sur le site,
|
||||
`php artisan migrate` applique l'équivalent et reconnaît les tables déjà créées.
|
||||
Le compte JDBC nécessite SELECT/INSERT/DELETE sur followers et SELECT/INSERT
|
||||
sur subscribers, en plus des permissions v2. Aucun DDL automatique par le mod.
|
||||
|
||||
L'intendance se gère dans le panneau admin ; seul son dernier message reste
|
||||
public en en-tête. La liaison de compte se fait avec le code généré sur le site
|
||||
et `/sanctuary community link <code>` en jeu (serveur authentifié requis).
|
||||
Les anciens boutons de liaison et d'historique public de l'intendance disparaissent.
|
||||
|
||||
En mode fichier, le panneau d’intendance en jeu s’ouvre avec
|
||||
`/sanctuary community admin` ; le serveur exige les permissions opérateur.
|
||||
|
||||
## Évolution beta.159 — recherche et lecture de l’intendance
|
||||
|
||||
Le schéma reste **v3** : aucune migration de données. Cette évolution remplace
|
||||
la restriction de lecture publique de beta.158 : les onglets Gazette, Tableau
|
||||
et Serveur permettent de lire leurs historiques. La publication et la gestion
|
||||
de l’intendance restent dans l’administration, sans réponse ni abonnement.
|
||||
|
||||
`list` et `cards` acceptent une recherche facultative dans `Request.body` (120
|
||||
caractères maximum, vide par défaut). Le site utilise le paramètre GET `q`,
|
||||
conservé dans la pagination. Recherche littérale de sous-chaîne dans le nom de
|
||||
l’auteur, le titre **ou** le corps, sans distinction de casse, avant pagination.
|
||||
Les accents restent significatifs et `%` / `_` sont des caractères ordinaires.
|
||||
Le filtre reste limité au serveur et à la rubrique sélectionnés ; les contenus
|
||||
masqués restent exclus. Les épingles gardent leur priorité. Le client attend
|
||||
300 ms après la frappe et ignore les réponses correspondant à un ancien filtre.
|
||||
Les pages restent bornées à 10 publications ; aucune recherche dans les réponses.
|
||||
|
||||
Les dates des publications affichent l’année (UTC). Le menu pause affiche la date
|
||||
complète dans la langue et le fuseau local du joueur. La galerie utilise la
|
||||
surface disponible du GUI, adapte ses colonnes et conserve ses pages de 24
|
||||
captures défilantes. Le clic ouvre un aperçu dans cette surface, en conservant
|
||||
les proportions et les boutons Retour / Assigner. Le formulaire conserve son
|
||||
aperçu compact. Les PNG sources ne sont pas modifiés.
|
||||
|
||||
Les demandes suivies ouvertes qui ont une position affichent un `!` sur la carte
|
||||
de pause et le grand atlas, dans la dimension correspondante. Elles ne révèlent
|
||||
pas le terrain et ne chargent aucun chunk. L’infobulle montre titre, auteur,
|
||||
résumé, coordonnées, matériaux et récompense ; les listes longues sont abrégées
|
||||
pour rester dans le GUI. Le clic ouvre l’annonce. « Voir sur la carte » centre
|
||||
l’atlas sur son lieu dans la dimension courante, sans téléportation. Les repères
|
||||
suivent tous les abonnements, indépendamment des trois suivis visibles dans le
|
||||
HUD ; ils disparaissent au désabonnement, à la clôture ou au masquage. Un
|
||||
rafraîchissement des suivis reste actif lorsque la carte ou la pause est ouverte
|
||||
(période de cinq secondes, différée si une autre requête est en cours).
|
||||
|
||||
Le message réseau `following` ajoute `summary`, extrait littéral du corps limité
|
||||
à 240 caractères Unicode plus une ellipse. Le texte intégral et les photos ne
|
||||
sont pas diffusés dans ce message. Aucun champ persistant supplémentaire.
|
||||
@@ -0,0 +1,81 @@
|
||||
# beta.159 — recherche communautaire et galerie adaptable
|
||||
|
||||
Branche `codex/community-search-gallery-beta159`, socle beta.158.
|
||||
|
||||
## Résultat
|
||||
|
||||
Une barre de recherche filtre les publications par auteur, titre ou texte côté
|
||||
serveur, avant pagination. Le filtre accompagne les pages suivantes, conserve
|
||||
les épingles et se réinitialise en effaçant le champ. Les réponses aux anciennes
|
||||
recherches sont ignorées et le focus de saisie est conservé.
|
||||
|
||||
Les trois onglets Gazette / Tableau / Serveur donnent accès aux publications de
|
||||
chaque rubrique. L’en-tête d’intendance de la pause ouvre aussi son historique.
|
||||
La lecture est publique ; la publication et la gestion des messages du serveur
|
||||
restent réservées au panneau administrateur, même pour un opérateur visitant
|
||||
l’écran public. Les messages du serveur n’acceptent ni réponses ni abonnements.
|
||||
|
||||
L’année figure dans la date de pause et les métadonnées des publications. La
|
||||
galerie utilise l’espace disponible aux différentes échelles de GUI et tailles
|
||||
de fenêtre. Les captures restent paginées par 24 avec défilement. Cliquer une
|
||||
capture ouvre un grand aperçu avec les commandes de retour et d’assignation.
|
||||
La photo assignée reste compacte dans le formulaire et les brouillons sont
|
||||
conservés lors du retour. Aucune modification des PNG originaux.
|
||||
|
||||
Les annonces suivies géolocalisées portent un « ! » sur les deux cartes.
|
||||
L’infobulle fournit titre, auteur, résumé, coordonnées, matériaux et récompense
|
||||
sans ouvrir la discussion ; le clic reste disponible. Un bouton dans l’annonce
|
||||
centre le grand atlas sur sa position, dans la dimension courante. Aucune
|
||||
téléportation ni révélation du terrain : seules les coordonnées publiées sont
|
||||
projetées. Les annonces fermées, masquées, sans lieu ou non suivies ne portent
|
||||
pas de repère. Les longues infobulles sont abrégées pour tenir dans le GUI.
|
||||
|
||||
Le site suit le même contrat de recherche et de lecture. Schéma v3 inchangé,
|
||||
aucune migration de sauvegarde ni de base. Libellés français et anglais.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Stockage fichier et MariaDB : recherche par auteur et corps, casse et accents,
|
||||
filtre avant pagination, épingles conservées, caractères `%` / `_` littéraux,
|
||||
rubrique et serveur isolés, contenus masqués exclus, borne de 120 caractères.
|
||||
- Client natif Minecraft 26.3 / Java 25 / Vulkan : galerie et aperçu en FR/EN,
|
||||
GUI 2/3/4 et fenêtres 1280×960 / 1920×1080 ; retour au brouillon, assignation,
|
||||
publication, listes défilantes et chargement des pages suivantes.
|
||||
- Recherche en jeu au-delà de la première page, filtre par joueur, effacement,
|
||||
conservation du focus et rejet d’une réponse correspondant à l’ancien filtre.
|
||||
- Lecture des messages serveur par un joueur ordinaire, aucun contrôle d’écriture
|
||||
dans les pages publiques, y compris pour l’opérateur ; administration inchangée.
|
||||
- Carte : projection dans la dimension correspondante, titre/auteur/résumé,
|
||||
matériaux et récompense au survol ; ouverture de la demande par clic sur les
|
||||
deux cartes ; retrait immédiat du repère après désabonnement.
|
||||
- Date avec année en FR/EN, GUI 2/3/4, sans chevauchement de l’intendance.
|
||||
- Site : 24 tests / 157 assertions sur SQLite puis MariaDB de développement,
|
||||
formatage Pint réussi. Commit local `e70e78d`, branche `codex/community-search`.
|
||||
|
||||
La commande complète `./gradlew :sanctuary:runClientGameTest check build assemblePack`
|
||||
avec les propriétés client Cards158, QuickTests et Vulkan a exécuté 252 GameTests :
|
||||
229 réussites et les mêmes 23 échecs que beta.158. Aucun test serveur supprimé
|
||||
ou modifié. La comparaison est conservée dans `build/search159-server-failures.json`
|
||||
et le journal dans `build/beta159-check-build.log`.
|
||||
|
||||
Les contrôles restants, la compilation et `assemblePack` passent avec la suite
|
||||
serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 133 tâches, journal
|
||||
`build/beta159-release.log`. Le parcours client final passe en mode MariaDB,
|
||||
puis en mode fichier (`build/beta159-file-client.log`). Les repères ont été
|
||||
vérifiés avec et sans terrain d’atlas débloqué ; les infobulles restent dans la
|
||||
fenêtre aux GUI 2/3/4. Captures représentatives inspectées :
|
||||
`build/search159-file-screens/` et `build/search159-database-screens/`.
|
||||
|
||||
L’export packwiz est vérifié : intégrité ZIP, version beta.159, JAR embarqué
|
||||
identique au JAR de livraison. Les 29 ressources de shaders sont identiques à
|
||||
beta.158, qui conserve le socle beta.151. Les anciens JAR et MRpack beta.154 à
|
||||
beta.158 restent présents. Aucun monde personnel, déploiement de serveur, canal
|
||||
packwiz ou instance Prism n’est modifié.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.159.jar` — SHA-256 `c30d57a5f37e0a5c9dbb387ca831ce9e5c15a1dfe4cb55af0af20a376e862576`.
|
||||
|
||||
- `build/Sanctuary-beta.159.mrpack` — SHA-256 `868932d88bc241bfb526a7c5e4054009e247658bd0784b7141d82017edcc2823`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Récap Discord — beta.130 à beta.160
|
||||
|
||||
Brouillon à relire avant publication. Les captures sont des scènes de test,
|
||||
pas des photos d'un serveur public. Aucune annonce envoyée automatiquement.
|
||||
|
||||
## Message 1 — l'image et la lumière
|
||||
|
||||
**Sanctuary — retour sur les beta.130 à 160**
|
||||
|
||||
On a d'abord beaucoup travaillé l'ambiance : bloom, minerais lumineux, rebonds
|
||||
de couleur, relief des matériaux, reflets, rayons du soleil et lumières portées.
|
||||
L'eau a reçu ses propres réglages, puis plusieurs passes de correction ont
|
||||
amélioré les transitions, les distances et les matières.
|
||||
|
||||
La version du shader conservée est celle du socle beta.151. Elle reste gourmande,
|
||||
mais on garde ce travail ! En beta.160, le shader devient **désactivé par défaut
|
||||
sur une nouvelle configuration**, et peut être réactivé dans les options.
|
||||
|
||||
Images : `01-minerais-bloom-beta130.png`, `02-eau-beta144.png`.
|
||||
|
||||
## Message 2 — la vie du serveur
|
||||
|
||||
Le chantier suivant rapproche Minecraft et le site Sanctuary : **Gazette,
|
||||
tableau d'affichage et intendance** partagent un contrat de publications.
|
||||
Le mod fonctionne avec des fichiers par défaut ; une base MariaDB permet de
|
||||
partager les données avec le site.
|
||||
|
||||
Le menu pause a été réorganisé autour des systèmes Sanctuary, de la carte et
|
||||
des publications. La Gazette accepte les articles avec une capture obligatoire,
|
||||
les réponses, les épingles et la recherche par joueur ou contenu.
|
||||
|
||||
Le tableau distingue les types d'annonces par couleur. Les demandes peuvent
|
||||
indiquer un lieu, des matériaux, une récompense et des participants. Ce sont
|
||||
des descriptions pour organiser les échanges entre joueurs : aucun coffre de
|
||||
dépôt ni paiement automatique. Une demande suivie apparaît avec un **! sur la
|
||||
carte**, et son résumé se lit au survol.
|
||||
|
||||
Images : `03-menu-et-quete-beta159.png`, `04-materiaux-beta158.png`.
|
||||
|
||||
## Message 3 — tester ensemble
|
||||
|
||||
Avec la beta.160, on prépare nos séances de test en binôme sur Mac : un petit
|
||||
serveur local, un seul Minecraft à l'écran et des personnages de laboratoire.
|
||||
Le but : vérifier les échanges côté technique pendant que les vrais essais en
|
||||
jeu nous montrent ce qu'il faut simplifier dans l'interface.
|
||||
|
||||
Des scènes courtes et des captures horodatées permettront de reprendre les
|
||||
moments où l'on hésite, cherche un bouton ou perd le fil. Le prochain chantier
|
||||
est l'ergonomie, dans le jeu comme sur le site.
|
||||
|
||||
## Sources et pièces jointes
|
||||
|
||||
Les images originales et leurs empreintes sont rassemblées dans
|
||||
`build/discord-beta130-160/` (ignoré par Git), avec `images.json` pour la provenance.
|
||||
Les captures anciennes illustrent leur version d'origine, sans revendiquer une
|
||||
nouvelle validation graphique. Les sondages restent une idée, pas une fonction livrée.
|
||||
|
||||
Références : `shader-beta130.md` à `nether-pbr-beta151.md`,
|
||||
`community-beta154.md`, `pause-redesign-beta155.md`, `gazette-photos-beta157.md`,
|
||||
`community-cards-beta158.md`, `community-search-beta159.md`, `duo-lab-beta160.md`.
|
||||
@@ -0,0 +1,155 @@
|
||||
# DUO-160 — laboratoire Mac et tests en binôme
|
||||
|
||||
Branche `codex/duo-lab-beta160`, base `beta.159`. Minecraft 26.3, Java 25,
|
||||
Fabric 0.19.5 ; mêmes dépendances. Validation graphique Vulkan uniquement.
|
||||
|
||||
## Contrat
|
||||
|
||||
Le shader natif est OFF quand sa préférence est absente. Un choix sauvegardé
|
||||
ON ou OFF reste respecté, ainsi que ses intensités. Aucun shader supprimé :
|
||||
les ressources beta.151 restent identiques. Le client du laboratoire reçoit
|
||||
explicitement `enabled:false`, indépendamment de l'installation personnelle.
|
||||
|
||||
Tout le laboratoire réside dans `build/duo/`, ignoré par Git. Le monde plat
|
||||
`duo-flat-160`, graine 160, sert aux interfaces et aux échanges ; il ne valide
|
||||
pas le terrain Sanctuary. Aucun monde personnel ouvert ou modifié.
|
||||
Le module `sanctuary-test` fournit les personnages uniquement avec
|
||||
`-Dsanctuary.duo=true`. Il ne fait pas partie du pack normal.
|
||||
|
||||
## Préparer et lancer
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary-test:exportDuoLaunch --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G
|
||||
python3 scripts/duo_lab.py prepare
|
||||
python3 scripts/duo_lab.py server
|
||||
# Dans un autre terminal :
|
||||
python3 scripts/duo_lab.py client
|
||||
```
|
||||
|
||||
L'export permet ensuite de lancer Java directement, sans conserver un processus
|
||||
Gradle pendant la séance. Préparer à nouveau ne remplace aucun fichier existant.
|
||||
Le client rejoint automatiquement `127.0.0.1:25575`. Au premier accès,
|
||||
choisir la couleur et le familier dans HELLO_WORLD puis entrer dans Sanctuary.
|
||||
Depuis la console serveur : `op KokaLab` donne les commandes au client de test.
|
||||
|
||||
Le serveur écoute seulement sur loopback et utilise des identités hors ligne
|
||||
pour ce laboratoire. Ce profil ne doit pas être exposé sur le réseau. La liaison
|
||||
authentifiée d'un compte web exige toujours un vrai serveur en mode en ligne ;
|
||||
elle n'est pas contournée par les outils du laboratoire.
|
||||
|
||||
Réglages de départ : serveur 256–768 Mio de heap, vue 4 chunks, simulation 3 ;
|
||||
client 512–2048 Mio, vue 6, simulation 4, 30 FPS, fenêtre 1280×720. La mémoire
|
||||
native, graphique et macOS s'ajoute au heap. Aucun engagement de tenir dans
|
||||
une consommation totale de 2,75 Gio. Ne pas exécuter la compilation en même
|
||||
temps qu'une séance. Augmenter seulement après mesure :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py server --memory 1024
|
||||
python3 scripts/duo_lab.py client --memory 2560
|
||||
```
|
||||
|
||||
Le mode fichier reste le défaut. Pour une préparation neuve avec le site local :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py prepare --storage database --server-id UUID_DU_SERVEUR \
|
||||
--jdbc jdbc:mariadb://127.0.0.1:33077/BASE_DE_TEST
|
||||
SANCTUARY_COMMUNITY_DB_PASSWORD='' python3 scripts/duo_lab.py server
|
||||
```
|
||||
|
||||
La base doit déjà avoir le schéma communautaire v3. Le site doit utiliser le
|
||||
même identifiant de serveur. Aucun schéma ni sauvegarde n'est migré ici.
|
||||
|
||||
## Personnages et séances
|
||||
|
||||
```text
|
||||
/duo spawn Alice
|
||||
/duo spawn Bob
|
||||
/duo list
|
||||
/duo remove Alice
|
||||
/duo trace start
|
||||
/duo trace stop
|
||||
```
|
||||
|
||||
Les personnages portent le préfixe `Lab_`, sont créatifs et apparaissent près
|
||||
de la source de commande. Limite de quatre, noms uniques de 1–12 caractères
|
||||
ASCII alphanumériques ou `_`. Ce sont des ServerPlayer sans transport réseau
|
||||
ni rendu, pas des IA et pas des clients réseau supplémentaires. On peut les
|
||||
cibler avec les commandes natives, par exemple `tp Lab_Bob ~2 ~ ~`.
|
||||
Ils ne valident pas seuls le protocole d'un deuxième client réel.
|
||||
|
||||
La trace consigne noms de test, dimension, positions et angles toutes les deux
|
||||
secondes dans `build/duo/server/duo-sessions/`. Activation explicite, arrêt au
|
||||
bout de cinq minutes maximum ou à la fermeture du serveur. Pas de frappe clavier,
|
||||
mot de passe, navigateur ou audio enregistré.
|
||||
|
||||
Pour la relecture visuelle, identifier la fenêtre Minecraft puis lancer :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py windows
|
||||
python3 scripts/duo_lab.py record --window ID_FENETRE_MINECRAFT --seconds 120
|
||||
```
|
||||
|
||||
Une séquence PNG horodatée toutes les deux secondes, un index HTML et une fiche
|
||||
de notes sont écrits dans `build/duo/sessions/`. Ctrl-C termine la capture ; limite
|
||||
de cinq minutes. macOS peut demander l'autorisation de capture. Cette version
|
||||
ne produit pas une vidéo continue et peut manquer une interaction très courte.
|
||||
On ne démarre une capture qu'au début d'une scène annoncée.
|
||||
|
||||
## Trois premières scènes
|
||||
|
||||
1. Ouvrir la Gazette, retrouver un auteur, lire un article et répondre. Vérifier
|
||||
que l'autre interface retrouve la réponse, puis noter les hésitations.
|
||||
2. Créer une demande avec lieu et matériaux, la suivre, lire son infobulle sur
|
||||
la carte, ouvrir la discussion puis arrêter le suivi.
|
||||
3. Afficher deux figurants, tester les interactions de proximité et comparer
|
||||
la fluidité à un seul joueur. Garder un véritable second client pour une
|
||||
vérification réseau ultérieure si nécessaire.
|
||||
|
||||
Le récap Discord se trouve dans `discord-beta130-160.md` ; les originaux des
|
||||
quatre illustrations sont copiés avec provenance dans `build/discord-beta130-160/`.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `./gradlew check build assemblePack` : 252 GameTests, 229 réussites et les
|
||||
mêmes 23 échecs que beta.159. Aucun échec ajouté ; comparaison dans
|
||||
`build/duo160-server-failures.json`, log `build/beta160-check-build.log`.
|
||||
- Contrôles restants, compilation, pack et client natif Vulkan réussis avec la
|
||||
suite serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 137 tâches,
|
||||
`build/beta160-release.log`. Le test `ShaderDefaults160ClientChecks` vérifie
|
||||
préférence absente, objet vide, ON explicite, OFF enregistré et réactivation,
|
||||
avec intensité personnelle conservée. Marqueur `SHADER160_DEFAULTS_PASS`.
|
||||
- Module de test reconstruit après ajustement du transport sans réseau : build
|
||||
réussi. L'export de lancement inclut les arguments fournis par Loom et élimine
|
||||
son argfile imbriqué, que Java ne peut pas réinterpréter depuis un autre argfile.
|
||||
- Serveur réel à 768 Mio : démarrage sur loopback, monde plat neuf ; création de
|
||||
quatre acteurs, doublon et cinquième refusés, retrait et acteur absent vérifiés.
|
||||
Le journal produit 56 observations JSON valides puis s'arrête sur commande.
|
||||
- Client réel séparé, Vulkan, 2048 Mio, shader OFF : connexion jusqu'à l'écran
|
||||
HELLO_WORLD vérifiée visuellement. La création du profil et le parcours en jeu
|
||||
avec le joueur restent à faire ensemble ; aucune session ergonomique humaine
|
||||
terminée ni validation d'un deuxième client réseau revendiquée.
|
||||
- Mesure ponctuelle avant l'entrée du joueur : serveur ~313 Mio de heap utilisé,
|
||||
client au menu ~289 Mio. Ce ne sont ni des pics ni une mesure de RAM système
|
||||
totale. Les figurants ne possèdent ni sockets ni fenêtre graphique.
|
||||
- Capture ciblée macOS essayée sur quatre secondes : séquence PNG, manifeste,
|
||||
index HTML et fiche de notes créés. Aucun enregistrement continu ou micro.
|
||||
- Export MRpack : intégrité ZIP, version et JAR embarqué vérifiés. Les 29 ressources
|
||||
de shaders sont inchangées depuis beta.159. JAR et packs beta.154–159 conservés.
|
||||
- Skill personnel `sanctuary-session-notes` installé et validé séparément dans
|
||||
`~/.codex/skills/` pour classer les dictées en Markdown. Il ne change pas le
|
||||
modèle du tour courant ; Luna/low peut être choisi dans une tâche dédiée.
|
||||
|
||||
Le serveur local utilise la base de développement du site, schéma v3 déjà présent,
|
||||
sous le même scope. Aucune validation de liaison de comptes hors ligne : cette
|
||||
opération reste réservée au mode authentifié. Aucun déploiement public, canal
|
||||
packwiz, serveur personnel ou instance Prism modifié.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.160.jar` — SHA-256
|
||||
`4c19e620e989050488c9a96aaddaf897a74115a9fb4c5099e5518ae32db5b2e8`.
|
||||
- `build/Sanctuary-beta.160.mrpack` — SHA-256
|
||||
`272e3460f35c8d9a4de689d30e4d952c3121923ba5fe30a23c83b3e76bd23bac`.
|
||||
- `build/duo/Jouer.command` et `Serveur.command` : raccourcis locaux de séance.
|
||||
|
||||
@@ -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,26 @@
|
||||
# beta.165 — Gazette en article et conversation
|
||||
|
||||
Photo en tête, titre et description dessous dans le même conteneur sombre.
|
||||
Auteur, visage, date et actions sous le conteneur. Réponses à droite avec
|
||||
défilement indépendant et saisie fixe ; sur GUI étroit, réponses sous l’article.
|
||||
Retour et Actualiser restent en haut, comme pour le tableau.
|
||||
|
||||
Interface cliente uniquement, sans changement de stockage ni de monde.
|
||||
Test client Vulkan réussi en FR/EN aux échelles GUI 2, 3 et 4 :
|
||||
position du panneau de réponses, repli sous l’article, défilement disponible,
|
||||
brouillon conservé après redimensionnement et réponse envoyée puis relue.
|
||||
Le scénario existant du tableau et celui du menu pause passent également.
|
||||
Captures conservées dans `build/qa-beta165/` (image synthétique de test).
|
||||
Inspection visuelle FR GUI 2 et GUI 4 effectuée.
|
||||
Journal : `build/beta165-client.log`.
|
||||
|
||||
`check build assemblePack --continue` exécuté : mêmes 23 GameTests en échec
|
||||
qu’en beta.164, aucun nouvel échec. Comparaison enregistrée dans
|
||||
`build/session165-server-failures.json`. La vérification générale reste en échec.
|
||||
Assemblage séparé réussi avec `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check` après cette vérification.
|
||||
|
||||
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.165.jar`.
|
||||
SHA-256 : `03a954caf68089862b1d237dcf1ebaa501e3ce3cfedb1f5184e9412e73fd711d`.
|
||||
Anciens JAR conservés ; aucune publication distante.
|
||||
|
||||
@@ -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,120 @@
|
||||
# beta.166 — inscription web et reprise de l’accueil
|
||||
|
||||
Intégration de la PR #1 de Chris (`8de569b`), avec son historique conservé par
|
||||
le merge `ad960ef`, sur beta.165 et ses 252 GameTests actualisés.
|
||||
|
||||
## Parcours
|
||||
|
||||
Le joueur ouvre son dossier `/candidature` sur le site, se connecte à Discord,
|
||||
dépose sa candidature et récupère le code après acceptation/parrainage. Le
|
||||
bouton **Récupérer mon code** de Hello World ouvre ce dossier, sur le site
|
||||
configuré par le serveur, avec la confirmation de lien native de Minecraft.
|
||||
Le code reste obligatoire avant la création du personnage. Gazette et tableau
|
||||
restent en consultation seule sur le site.
|
||||
|
||||
Sans configuration d’accès, le mod autonome garde l’accueil sans code.
|
||||
Avec une configuration partielle/invalide, l’accueil reste fermé. Un habitant
|
||||
déjà arrivé n’a pas à présenter de code ni à connaître le nouveau payload d’accès.
|
||||
Les autres contrôles de compatibilité Sanctuary restent appliqués.
|
||||
|
||||
## Contrat API de reprise
|
||||
|
||||
Les POST authentifiés `/api/v1/access-codes/verify` et `/redeem` reçoivent :
|
||||
`code`, `minecraft_username` et `admission_id` (UUID). Le serveur calcule cette
|
||||
admission à partir d’un identifiant de monde persistant et de l’UUID du joueur ;
|
||||
le client ne la choisit jamais. Un autre joueur ou un autre monde possède une
|
||||
admission différente, même à graine identique.
|
||||
|
||||
La consommation en base conserve `used_at` et `admission_id` dans la même
|
||||
transaction verrouillée. Une répétition de la même admission et du même pseudo
|
||||
retourne `valid: true, reason: resumed`, le pseudo, le Discord et l’admission.
|
||||
`verify` accepte aussi cette reprise ; le bouton de confirmation redevient
|
||||
accessible. Une autre admission retourne `already_used` sans identité. Un pseudo
|
||||
incorrect ou un code révoqué reste refusé. Les codes anciennement consommés sans
|
||||
reçu restent refusés ; leur récupération nécessite une réémission par le staff.
|
||||
|
||||
Le mod exige une réponse positive portant son admission exacte. Il n’accepte
|
||||
plus `already_used` sur la seule présence d’un pseudo et d’un Discord. Une
|
||||
réponse positive arrivée après déconnexion est quand même enregistrée, mais ne
|
||||
crée pas le personnage hors connexion. Si la réponse est entièrement perdue,
|
||||
le même code est vérifiable et consommable à nouveau au prochain accueil.
|
||||
Le reçu local sauvegardé permet de terminer une création déjà autorisée.
|
||||
Les erreurs réseau et de jeton proposent une nouvelle vérification.
|
||||
|
||||
## Migration et activation
|
||||
|
||||
1. Sauvegarder la base web et les fichiers de progression du serveur.
|
||||
2. Appliquer la migration additive web `add_admission_receipt_to_access_codes_table`
|
||||
(colonne UUID nullable, aucune donnée supprimée), puis déployer le service API.
|
||||
3. Installer beta.166 sur serveur et clients des nouveaux arrivants.
|
||||
4. Configurer `SANCTUARY_APP_URL` et `SANCTUARY_API_TOKEN`, ou
|
||||
`config/sanctuary/access.json` (`appUrl`, `token`). Le jeton reste serveur.
|
||||
Utiliser HTTPS hors du labo local et un serveur Minecraft en online-mode pour
|
||||
authentifier les UUID ; le labo offline ne prouve pas l’identité d’un compte.
|
||||
|
||||
Dans le monde, `data/sanctuary-admission-id` est créé atomiquement avant tout
|
||||
appel API et doit être sauvegardé avec `data/sanctuary-access.json`. Aucun code
|
||||
brut ni jeton n’est enregistré dans ces fichiers. Leur corruption ferme l’accès
|
||||
et conserve les preuves. Les formats habitants/starter et les chunks restent
|
||||
inchangés. Ne pas supprimer l’identifiant d’admission pour « réparer » un accès.
|
||||
|
||||
Retour arrière : conserver la colonne web et les reçus, désactiver explicitement
|
||||
l’accès si l’on revient à un ancien mod (cela réouvre l’accueil sans code).
|
||||
Ne pas exécuter le rollback de migration après consommation : il supprimerait
|
||||
les reçus nécessaires à la reprise. Pas de déploiement public implicite.
|
||||
|
||||
## Laboratoire
|
||||
|
||||
Le monde beta.160 existant est conservé. La reprise de l’introduction utilise
|
||||
un nouveau monde de développement ; les réglages graphiques et anciens JAR sont
|
||||
conservés. `scripts/duo_lab.py server --intro` joue la vraie introduction sur le
|
||||
monde plat ; sans cette option, le profil de test continue de la sauter.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `check build assemblePack` réussit (10 min 12 s), avec **252/252 GameTests**.
|
||||
Trace : `build/access166-full.log`.
|
||||
- Smoke du contrat et du ledger : reprise, mauvais compte, mauvais reçu, absence
|
||||
de reçu, isolation entre mondes de même graine, persistance et corruption.
|
||||
- `access166Interop` réussit contre la vraie API PHP/MariaDB locale : réponse de
|
||||
consommation ignorée, nouveau ledger, nouvelle vérification et confirmation,
|
||||
refus d’un autre joueur et d’une autre admission (`build/access166-interop.log`).
|
||||
- Test client Vulkan : vrai handshake Hello World, endpoint HTTP contrôlé qui
|
||||
consomme puis répond 503, nouvelle vérification, confirmation et un seul reçu
|
||||
bound. Ce test n’utilise pas Discord et ne prétend pas vérifier OAuth.
|
||||
- Site : 12 tests inscription/candidature (68 assertions), puis 31 tests ciblés
|
||||
inscription/redirection Discord/communauté (258 assertions), tous réussis.
|
||||
Pint passe. Suite web entière : 74 réussis, 1 ignoré et 1 échec préexistant
|
||||
`PlayerModerationTest::test_an_administrator_can_open_a_player_profile`
|
||||
(ancien libellé « Nommer admin »). Reproduit dans un instantané de HEAD avant
|
||||
modification ; pas une régression de l’inscription.
|
||||
- Base locale sauvegardée avant la migration, puis migration additive appliquée.
|
||||
Le vrai OAuth Discord reste à valider avec les identifiants de l’application ;
|
||||
aucun contournement d’authentification ou compte de démonstration pour le joueur
|
||||
n’est installé. Un compte API jetable distinct sert uniquement à l’interop.
|
||||
- Aucun déploiement sur sanctuary-minecraft.net, aucun push/fusion distante,
|
||||
aucune modification de l’ancienne sauvegarde du labo ni des JAR archivés.
|
||||
|
||||
Après la passe complète, le retour immédiat de la vérification en cache a été
|
||||
ajusté pour qu’une nouvelle tentative ne reste pas sans réponse pendant la
|
||||
limite d’une seconde. Le scénario client exerce précisément cette reprise.
|
||||
`check build assemblePack` a ensuite réussi avec les 6 GameTests de progression
|
||||
ciblés (`build/access166-final.log`). La disposition compacte a été resserrée
|
||||
pour garder tous les contrôles dans un GUI de 240 pixels de haut ; ses captures
|
||||
FR/EN et assertions de limites sont vérifiées par le scénario client.
|
||||
|
||||
Le correctif web est enregistré dans `f36cc36`. Les identifiants OAuth fournis
|
||||
par l’utilisateur sont chargés depuis le `.env` privé et la redirection réelle
|
||||
vers Discord fonctionne avec le callback local. Le retour authentifié reste
|
||||
à confirmer après la connexion/autorisation de l’utilisateur.
|
||||
|
||||
La livraison finale et les captures compactes passent dans
|
||||
`build/access166-delivery.log` : scénario client Vulkan FR/EN et assemblage du
|
||||
pack. Cette dernière commande exclut `:sanctuary:check` pour éviter une troisième
|
||||
exécution générale après les deux contrôles réussis ci-dessus ; elle ne remplace
|
||||
pas ces résultats. La seule modification de jeu depuis le contrôle ciblé est
|
||||
la disposition compacte, couverte par ce dernier test client.
|
||||
|
||||
Le labo reste arrêté, configuré pour `duo-registration-166`. L'ancien
|
||||
`duo-flat-160` n'est pas modifié. Lancement prévu après connexion au dossier web :
|
||||
`python3 scripts/duo_lab.py server --memory 768 --intro`, puis le client habituel.
|
||||
@@ -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,66 @@
|
||||
# beta.162 — scène du tableau et conversation
|
||||
|
||||
Ticket issu du retour de séance du 24 septembre : conserver Retour/Actualiser en
|
||||
haut, montrer l’auteur avec son skin orienté vers la souris et sa demande dans
|
||||
une bulle colorée, puis les réponses à droite (dessous sur GUI étroit).
|
||||
|
||||
Modification client uniquement, sans migration, écriture dans les mondes ni
|
||||
changement des contrats fichier/MariaDB. Réponses, modération et suivi gardent
|
||||
leurs validations serveur. Le portrait est un rendu GUI, pas une entité.
|
||||
|
||||
## Présentation
|
||||
|
||||
- Retour et Actualiser restent hors de la zone défilante.
|
||||
- Le portrait utilise le skin de la connexion de l’auteur s’il est présent,
|
||||
sinon le cache de profils Minecraft asynchrone. Le skin natif de repli apparaît
|
||||
pendant le chargement ou si le profil ne peut pas être résolu (notamment les
|
||||
comptes fictifs/hors ligne). Aucun téléchargement synchrone dans le rendu.
|
||||
- Une bulle persistante encadrée montre catégorie/état, titre puis texte complet.
|
||||
Information : jaune clair sur fond sombre jaune ; les autres catégories gardent
|
||||
leurs couleurs. Les cartes de liste et du menu pause suivent aussi ce jaune.
|
||||
- Le portrait tourne doucement dans des angles bornés selon la position de la
|
||||
souris, sans capturer ses mouvements et sans clic requis.
|
||||
- Demande et discussion défilent séparément si l’espace le permet ; le champ de
|
||||
réponse et son bouton restent en bas de la discussion. Sur GUI étroit/bas,
|
||||
un défilement unique garde les réponses sous la scène.
|
||||
- Matériaux, lieu, récompense, suivi, visages des réponses et modération sont
|
||||
conservés. Les détails d’une demande vide ne sont plus affichés artificiellement.
|
||||
- Aucune animation de parole ni synchronisation vocale : « parle » désigne ici
|
||||
la mise en scène visuelle avec la bulle.
|
||||
|
||||
## Vérification
|
||||
|
||||
- Compilation Java 25 / Minecraft 26.3 réussie.
|
||||
- Parcours natif Vulkan : `NOTICE162_SCENE_PASS`, puis
|
||||
`SEARCH159_CLIENT_PASS mode=file`, build réussi en 1 min 59 s.
|
||||
- FR/EN, GUI 2/3/4 : scène, passage en colonne unique, boutons de retour au-dessus,
|
||||
réponse au-dessus du footer en deux colonnes, angles du portrait bornés et
|
||||
changement d’orientation avec la souris.
|
||||
- Brouillon conservé pendant les redimensionnements ; publication d’une réponse
|
||||
et lecture de son contenu après rechargement serveur vérifiées.
|
||||
- Régressions communautaires : photos, recherche, suivi/désabonnement, marqueur
|
||||
de carte et infobulle, restrictions de l’Intendance toujours validés.
|
||||
- Captures relues : `build/qa-beta162/0042_notice162-scene-fr_fr-2.png` et
|
||||
`0043_notice162-scene-fr_fr-3.png`. Conversation : `0048_notice162-conversation.png`.
|
||||
- `./gradlew check build assemblePack` lancé : 252 GameTests terminés, 229 réussis
|
||||
et 23 échecs, exactement les mêmes qu’en beta.161 (aucun ajouté/retiré).
|
||||
Liste comparée : `build/session162-server-failures.json`.
|
||||
- Contrôles restants et assemblage avec exclusion ponctuelle de `runGameTest` :
|
||||
réussis en 3 min 16 s (`check build assemblePack -x :sanctuary:runGameTest`).
|
||||
Aucune assertion désactivée dans les sources. Log : `build/beta162-assembly.log`.
|
||||
|
||||
Aucune modification de persistance : cette passe native utilise le mode fichier ;
|
||||
le laboratoire MariaDB partagé a été relancé avec la même interface, KokaLab
|
||||
connecté sur le monde existant `duo-flat-160`, Alice/Bob recréés, shader désactivé. Les figurants
|
||||
et profils hors ligne peuvent afficher un skin Minecraft de repli.
|
||||
|
||||
## Livraison locale
|
||||
|
||||
JAR : `mods/sanctuary/build/libs/sanctuary-beta.162.jar`.
|
||||
Pack assemblé : `build/packwiz`.
|
||||
SHA-256 : `35c5d82eba110ee2150f73baf6341efc90f87971965a3b4dd2bcd04add0a59ff`.
|
||||
|
||||
Branche `codex/notice-scene-beta162`, tag local `beta.162`. Anciens JAR conservés.
|
||||
Aucune publication distante ni synchronisation de Prism dans cette livraison.
|
||||
Les 23 échecs préexistants restent ouverts ; les essais graphiques ne démontrent
|
||||
pas la résolution réseau d’un skin officiel absent dans ce laboratoire hors ligne.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Distribution packwiz et Prism
|
||||
|
||||
## Rattrapage des releases du 24 septembre 2026
|
||||
|
||||
Les binaires conservés des beta.145–151 et beta.154–166 sont publiés dans Gitea.
|
||||
La beta.144 existante reste intacte ; les 152–153 demeurent des chantiers non livrés.
|
||||
[Inventaire, empreintes et limites](publications-beta144-166.md).
|
||||
Cette publication ne modifie ni le canal packwiz ni les installations Prism.
|
||||
|
||||
|
||||
Sanctuary Beta utilise une seule instance Prism, synchronisée avant chaque
|
||||
lancement par packwiz. Le canal reste à l'adresse :
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# beta.164 — calendrier compact et intendance alignée
|
||||
|
||||
Calendrier à la largeur de la navigation : jour agrandi et heure sur la première
|
||||
ligne, mois et année ensemble dessous. Le titre serveur est aligné à gauche
|
||||
et couvre la carte ainsi que les panneaux Gazette/Tableau.
|
||||
|
||||
Interface cliente uniquement. Aucun changement de stockage ou de monde.
|
||||
|
||||
Vérification client Vulkan réussie : FR/EN aux échelles GUI 2, 3 et 4.
|
||||
Le test contrôle la largeur du calendrier, l’heure dans son libellé accessible,
|
||||
les limites de l’intendance et les interactions communautaires existantes.
|
||||
Captures conservées dans `build/qa-beta164/` et journal dans
|
||||
`build/beta164-client-retry.log`. Inspection visuelle FR GUI 4 et EN GUI 2 effectuée.
|
||||
Le premier test a détecté que StringWidget redimensionnait le calendrier lors
|
||||
du changement de texte ; la largeur de navigation est maintenant réappliquée.
|
||||
|
||||
`check build assemblePack --continue` exécuté : les mêmes 23 GameTests qu’en
|
||||
beta.163 échouent, aucun nouvel échec (comparaison enregistrée dans
|
||||
`build/session164-server-failures.json`). La suite complète reste en échec.
|
||||
L’assemblage séparé `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check` réussit après ce contrôle.
|
||||
|
||||
Anciennes archives conservées ; aucune publication distante.
|
||||
JAR : `mods/sanctuary/build/libs/sanctuary-beta.164.jar`.
|
||||
SHA-256 : `5bdfb097004a7932a075fbce9013556266f76ddd491ca21a24e12609c8600e53`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# beta.163 — cadre du menu pause
|
||||
|
||||
Retour de séance : Découverte/Progression séparés de Habitant/Factions/Combat,
|
||||
bouton Monde retiré, séparateurs natifs de Progression, reprise centrée dans le
|
||||
footer, calendrier à gauche, message serveur au centre et horloge à droite.
|
||||
|
||||
Interface cliente uniquement, sans migration ni modification de règles ou de
|
||||
mondes. « Options du monde » reste dans les réglages en bas à gauche ; seul le
|
||||
raccourci de navigation Monde disparaît. La carte centrale reste disponible.
|
||||
|
||||
Vérifications :
|
||||
- Test client natif Vulkan réussi : FR/EN aux échelles GUI 2, 3 et 4,
|
||||
calendrier, séparation des groupes, limites des widgets et reprise centrée.
|
||||
Captures dans `build/qa-beta163/` ; journal `build/beta163-client-retry.log`.
|
||||
- Le scénario communautaire vérifie aussi la scène du tableau et les échanges.
|
||||
Une première exécution a expiré pendant une publication ; le test respecte
|
||||
désormais le délai serveur entre deux écritures.
|
||||
- `check build assemblePack --continue` : 23 échecs GameTest, ensemble identique
|
||||
à beta.162, aucun nouvel échec. Les autres tâches ont terminé ; la suite
|
||||
complète reste donc en échec. Comparaison dans `build/session163-server-failures.json`.
|
||||
- Assemblage réussi avec `build assemblePack :sanctuary-test:exportDuoLaunch
|
||||
-x :sanctuary:check`, après la vérification complète.
|
||||
|
||||
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.163.jar`.
|
||||
SHA-256 : `72df10885d1bcb3b623c46bddf361839c23f06dfd543119f91ac5e006a8f961e`.
|
||||
Aucune publication distante ; anciens JAR conservés.
|
||||
|
||||
@@ -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,44 @@
|
||||
# Publications beta.144 à beta.166
|
||||
|
||||
Rattrapage des releases Gitea du 24 septembre 2026. La beta.144 était déjà publiée ; ses tags et fichiers restent inchangés. Les archives suivantes sont publiées avec leurs binaires conservés, sans reconstruire ni renuméroter les anciens mods.
|
||||
|
||||
| Version | Commit source | Fichiers (hors SHA-256) |
|
||||
| --- | --- | --- |
|
||||
| [beta.145](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.145) | `ed2d08827260` | sanctuary-beta.145.jar, Sanctuary-Test-beta.145.mrpack, Sanctuary-beta.145.mrpack, Sanctuary-Template-beta.145.zip |
|
||||
| [beta.146](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.146) | `cf9a670f4415` | sanctuary-beta.146.jar, Sanctuary-beta.146.mrpack, Sanctuary-Template-beta.146.zip |
|
||||
| [beta.147](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.147) | `96a36d99bea0` | sanctuary-beta.147.jar, Sanctuary-beta.147.mrpack, Sanctuary-Template-beta.147.zip |
|
||||
| [beta.148](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.148) | `6f871446bbbf` | sanctuary-beta.148.jar, Sanctuary-beta.148.mrpack, Sanctuary-Template-beta.148.zip |
|
||||
| [beta.149](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.149) | `1c23d0c17ec9` | sanctuary-beta.149.jar, Sanctuary-beta.149.mrpack, Sanctuary-Template-beta.149.zip |
|
||||
| [beta.150](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.150) | `448e2febff2a` | sanctuary-beta.150.jar, Sanctuary-beta.150.mrpack, Sanctuary-Template-beta.150.zip |
|
||||
| [beta.151](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.151) | `b97ec6c9e820` | sanctuary-beta.151.jar, Sanctuary-beta.151.mrpack, Sanctuary-Template-beta.151.zip |
|
||||
| [beta.154](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.154) | `0eb1a381d4d3` | sanctuary-beta.154.jar, Sanctuary-beta.154.mrpack, Sanctuary-Template-beta.154.zip |
|
||||
| [beta.155](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.155) | `3bfb3cb251a1` | sanctuary-beta.155.jar, Sanctuary-beta.155.mrpack, Sanctuary-Template-beta.155.zip |
|
||||
| [beta.156](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.156) | `32158e2e0513` | sanctuary-beta.156.jar, Sanctuary-beta.156.mrpack, Sanctuary-Template-beta.156.zip |
|
||||
| [beta.157](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.157) | `9791d178ab5e` | sanctuary-beta.157.jar, Sanctuary-beta.157.mrpack, Sanctuary-Template-beta.157.zip |
|
||||
| [beta.158](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.158) | `19aa8054a52f` | sanctuary-beta.158.jar, Sanctuary-beta.158.mrpack, Sanctuary-Template-beta.158.zip |
|
||||
| [beta.159](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.159) | `bdaad0c94032` | sanctuary-beta.159.jar, Sanctuary-beta.159.mrpack, Sanctuary-Template-beta.159.zip |
|
||||
| [beta.160](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.160) | `c1d22786745d` | sanctuary-beta.160.jar, Sanctuary-beta.160.mrpack, Sanctuary-Template-beta.160.zip |
|
||||
| [beta.161](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.161) | `8deba7cfb47d` | sanctuary-beta.161.jar, Sanctuary-Template-beta.161.zip |
|
||||
| [beta.162](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.162) | `0c55a375623b` | sanctuary-beta.162.jar, Sanctuary-Template-beta.162.zip |
|
||||
| [beta.163](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.163) | `905b3bcfa125` | sanctuary-beta.163.jar, Sanctuary-Template-beta.163.zip |
|
||||
| [beta.164](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.164) | `9a429c40a15b` | sanctuary-beta.164.jar, Sanctuary-Template-beta.164.zip |
|
||||
| [beta.165](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.165) | `dbb9e3428da2` | sanctuary-beta.165.jar, Sanctuary-Template-beta.165.zip |
|
||||
| [beta.166](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.166) | `460c4706d37a` | sanctuary-beta.166.jar, Sanctuary-Template-beta.166.zip |
|
||||
|
||||
## Historique et intégration
|
||||
|
||||
La PR [#1 de Chris](https://git.botsu.net/koka/sanctuary-beta/pulls/1), intitulée « beta.144 — Code d’accès », est intégrée par le merge `ad960ef`, puis corrigée et livrée dans `460c470` (beta.166). Son numéro de version historique ne remplace pas la release beta.144 dédiée à l’eau. `main` a été avancé en fast-forward sur beta.166. La PR est clôturée : Gitea refuse le mode « manually-merged » dans ce dépôt, elle reste donc affichée comme fermée plutôt que fusionnée. Sa branche et ses commits sont conservés.
|
||||
|
||||
Les beta.152 et beta.153 sont des chantiers shaders non finalisés : aucun commit de livraison ni JAR Sanctuary vérifiable retrouvé. Aucun tag ou binaire artificiel n’est créé pour remplir ces numéros. Les fichiers de travail locaux sont conservés.
|
||||
|
||||
## Contrôles et limites
|
||||
|
||||
- Intégrité ZIP, version Fabric et version source contrôlées pour chaque JAR ; sources Java archivées comparées aux commits de livraison.
|
||||
- Le ZIP de sources beta.145 est antérieur à une correction : il n’est pas publié. Le JAR correspond exactement au SHA-256 documenté dans le ticket, et la classe concernée correspond au binaire beta.146 dont les sources sont vérifiées.
|
||||
- Les MRpack embarquent le JAR de leur release, vérifié par SHA-256. Le MRpack beta.150 conservé contenait un binaire intermédiaire : une copie a été réassemblée avec le JAR final ; l’original local reste intact.
|
||||
- Chaque pièce jointe est retéléchargée anonymement et comparée à son SHA-256 local. Chaque release comporte son fichier SHA256SUMS.
|
||||
- Les anciens échecs de tests restent documentés dans les tickets liés aux releases historiques. Cette publication rétrospective ne prétend pas les avoir corrigés dans les anciens binaires.
|
||||
- La beta.166 a passé `check build assemblePack`, avec 252/252 GameTests ; voir [le contrat et les vérifications](inscription-web-beta166.md). Le parcours OAuth Discord et l’inscription sous le pseudo du dossier ont ensuite été testés localement.
|
||||
- Site compagnon : branche `codex/inscription-web-beta166`, commit `89973c1` publié. Les 12 tests ciblés accès/parrainage passent (162 assertions), y compris le refus d’un autre pseudo et la génération d’invitation refusée à un candidat en attente même administrateur.
|
||||
|
||||
Le canal packwiz et les installations personnelles ne sont pas modifiés par ce rattrapage. Aucun monde, ancien JAR ou secret n’est ajouté à Git. Les fichiers `.env`, sauvegardes SQL et configurations privées du labo restent locaux.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Revue PR #1 de Chris — intégration sur beta.165
|
||||
|
||||
PR : [beta.144 — Code d’accès à la création Hello World](https://git.botsu.net/koka/sanctuary-beta/pulls/1).
|
||||
Révision examinée : `8de569bbf188f7b76ede9235d3a2e5449907bcd5`.
|
||||
Socle local : beta.165 + audit documentaire ; les corrections de GameTests restent
|
||||
un chantier distinct. Dépôt web local examiné : `sanctuary-web-community`, commit
|
||||
`1feab73`. Vérification du distant web après fetch : `6640966b7306b36735c622cc4e75f31467e6f249`, aucun écart sur `AccessCodeService.php` et `routes/api.php`. Inventaire API : une PR mod ouverte, aucune PR web ouverte.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Intégrable, mais ne pas fusionner telle quelle.** Le principal blocage est la
|
||||
reprise après consommation du code côté site. Aucun merge distant, publication,
|
||||
activation du contrôle d’accès ou commentaire à Chris n’a été effectué.
|
||||
|
||||
## Constats
|
||||
|
||||
### P1 — code consommé sans possibilité de reprendre l’accueil
|
||||
|
||||
Dans `ProgressionService.java` de la PR, lignes 375–382, le serveur appelle
|
||||
`redeem` puis ignore le résultat si la session s’est déconnectée avant le retour.
|
||||
Le site peut donc avoir consommé le code alors que le ledger local n’a jamais
|
||||
été écrit. Une coupure réseau après consommation, avant réception de la réponse,
|
||||
produit la même fenêtre.
|
||||
|
||||
Le site actuel (`AccessCodeService.php:71–72`) répond `already_used` sans
|
||||
`minecraft_username` ni `discord_id`. `AccessCodes.redeem` exige ces champs pour
|
||||
la reprise et refuse donc. De plus, même si le site est corrigé pour les renvoyer,
|
||||
`AccessCodes.verifyKey:50` transforme encore tout `already_used` en `code_used` ;
|
||||
`HelloWorldScreen.updateSelection` ne permet de confirmer que `code_recognized`
|
||||
ou un pending local. La réparation doit donc couvrir **le site, le serveur et
|
||||
le parcours client**, pas seulement ajouter deux champs à la réponse API.
|
||||
|
||||
Correction recommandée : un contrat de consommation rejouable pour le même compte
|
||||
et la même admission, avec identité confirmée par l’API serveur authentifiée ;
|
||||
réconciliation du résultat après déconnexion et reprise au prochain accueil.
|
||||
Un code révoqué, un autre compte et une requête non autorisée doivent rester
|
||||
refusés. Vérifier l’identité complète et la portée du reçu avant de l’accepter.
|
||||
Le ledger ne doit jamais conserver le code brut ni le jeton API.
|
||||
|
||||
La documentation actuelle de la PR dit qu’une déconnexion avant `redeemed`
|
||||
ne consomme pas le code : ce n’est pas garanti dès que le POST est parti.
|
||||
Le smoke teste un ledger après `remember`, pas la fenêtre avant cette écriture.
|
||||
|
||||
### P2 — compatibilité des clients existants à expliciter
|
||||
|
||||
Le nouveau contrôle de capacité `AccessPayloads.Gate` dans CONFIGURE est placé
|
||||
avant la recherche de l’habitant. Activer l’accès impose donc aussi la mise à
|
||||
jour des clients des habitants déjà enregistrés. La promesse « habitant existant
|
||||
entre sans écran » ne signifie pas compatibilité avec un ancien client.
|
||||
Décider et tester ce contrat avant activation : mise à jour coordonnée ou
|
||||
capacité exigée uniquement pour les accueils qui utilisent le code.
|
||||
|
||||
## Compatibilité avec nos changements
|
||||
|
||||
`git merge-tree --write-tree HEAD origin/pr-1` a été exécuté sans changer le
|
||||
checkout. Trace ignorée : `build/pr1-merge-tree.txt`.
|
||||
|
||||
- Conflits textuels uniquement dans `gradle.properties` et `packwiz/pack.toml`.
|
||||
**Ne pas reprendre beta.144** : réserver le prochain numéro libre au moment
|
||||
de la livraison, synchroniser les manifestes et garder les archives existantes.
|
||||
- Les changements de progression et Hello World s’appliquent sans conflit textuel
|
||||
au socle examiné. Cela ne constitue pas une preuve de compatibilité fonctionnelle.
|
||||
- Pas de changement proposé aux tables communautaires ou aux pages Gazette/Tableau.
|
||||
L’admission appelle une API HTTP du site ; elle n’utilise pas directement le
|
||||
stockage des publications. Tester quand même fichier et MariaDB en régression.
|
||||
- Sans URL/jeton, la fonctionnalité se désactive. Le laboratoire actuel ne doit pas
|
||||
être activé implicitement : ses comptes locaux doivent rester accessibles.
|
||||
- Le ledger ajoute `data/sanctuary-access.json` sans réécrire le registre des
|
||||
habitants. Prévoir sauvegarde et comportement de retour arrière avant activation.
|
||||
|
||||
## Plan d’intégration concret
|
||||
|
||||
1. Finaliser et enregistrer les corrections de tests séparément.
|
||||
2. Préparer une branche d’intégration depuis ce socle et importer le changement
|
||||
de Chris, en résolvant uniquement les numéros de version puis les adaptations
|
||||
réellement nécessaires. Conserver son attribution.
|
||||
3. Corriger et documenter ensemble le contrat de reprise site/mod ; écrire les
|
||||
tests du POST consommé avec réponse perdue et déconnexion concurrente.
|
||||
4. Valider l’accueil sans configuration, l’accueil configuré neuf, un habitant
|
||||
existant, client ancien, code invalide, mauvais pseudo, code révoqué, panne
|
||||
du site, jeton rejeté, deux confirmations et redémarrage entre les écritures.
|
||||
Vérifier qu’un seul habitant et un seul familier sont créés et qu’aucun coût
|
||||
n’est dupliqué.
|
||||
5. Jouer le parcours réel sur un serveur jetable avec API de test, puis rejouer
|
||||
les échanges Gazette/Tableau en fichier et MariaDB. Garder le site public
|
||||
communautaire en lecture seule.
|
||||
6. Livrer et taguer le nouveau compteur ; activer seulement sur le serveur
|
||||
explicitement visé, après configuration et sauvegarde.
|
||||
|
||||
Pas de compilation ni d’essai de handshake de cette PR revendiqués ici.
|
||||
La suite en cours concerne les tests actualisés de beta.165, pas le code de Chris.
|
||||
@@ -0,0 +1,78 @@
|
||||
# beta.161 — correctifs de la séance du 23 septembre
|
||||
|
||||
Ticket : R002–R009 du carnet de séance. Branches `codex/session-fixes-beta161`.
|
||||
|
||||
## Contrat de données et migration additive
|
||||
|
||||
Aucun monde ni chunk n’est régénéré, aucun format existant n’est modifié.
|
||||
Le nouveau fichier `data/sanctuary-server-metrics.json` version 1 conserve le temps
|
||||
réel cumulé depuis l’activation du compteur, les morts connues par UUID et les
|
||||
UUID des figurants exclus. Au premier démarrage, les morts sont importées des
|
||||
statistiques Minecraft déjà présentes ; le temps antérieur n’est pas inventé.
|
||||
Les valeurs vivantes sont relevées côté serveur, sauvegardées toutes les 30 secondes
|
||||
et à l’arrêt. Les arrêts ne comptent pas dans la durée. Un crash peut perdre au
|
||||
plus la période depuis le dernier relevé enregistré. Un fichier corrompu ou
|
||||
modifié extérieurement arrête la collecte sans le remplacer.
|
||||
|
||||
En mode base, appliquer la migration Web additive `create_sanctuary_server_metrics_table`
|
||||
avant de lancer le mod. Elle crée une table indépendante, une ligne par server_id ;
|
||||
le schéma communautaire v3 et ses données restent inchangés. Le serveur publie un
|
||||
instantané toutes les 30 secondes et un état arrêté à l’arrêt propre. En cas de
|
||||
panne SQL le compteur local continue ; le site marque la dernière mesure périmée
|
||||
après 90 secondes. Une table absente ne bloque pas les publications communautaires.
|
||||
La suppression de cette nouvelle table au rollback perd uniquement les instantanés,
|
||||
recréés depuis le fichier local à la prochaine publication. Ne pas supprimer le fichier
|
||||
local pour revenir en arrière ; les anciennes versions l’ignorent.
|
||||
Un seul serveur actif par server_id, comme pour les publications. Les morts de tous
|
||||
les joueurs ayant des statistiques sont cumulées, y compris les joueurs déconnectés.
|
||||
Les figurants du laboratoire sont exclus des morts et des joueurs en ligne.
|
||||
|
||||
## Résultat livré
|
||||
|
||||
Liste des joueurs sur U (migration unique de Tab ; autres choix conservés), suivi
|
||||
et place unique expliqués dans les demandes. Site : retour au dessin d’origine,
|
||||
intendance limitée au titre, page officielle avec retour, pas de navbar de rubriques
|
||||
ni de sélecteur de langue ni d’incitation à se connecter dans les publications.
|
||||
Gazette en accordéon exclusif : le dernier article visible est ouvert au chargement,
|
||||
ouvrir un autre referme le précédent. Photo entière à gauche, extrait court à droite,
|
||||
visage/pseudo/date et lien de lecture alignés en bas. Calendrier jour/mois encadré,
|
||||
message officiel à droite, sans heure ; année dans le footer.
|
||||
Les pages publiques du site sont en lecture seule, y compris leurs anciennes
|
||||
routes d’écriture pour les comptes connectés et le staff. L’administration
|
||||
Intendance réservée au staff reste disponible. Les joueurs publient, répondent
|
||||
et suivent les demandes dans Minecraft. Compteurs réels et état périmé explicite.
|
||||
|
||||
## Vérifications
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `./gradlew check build assemblePack` | 252 GameTests : 229 réussis, les mêmes 23 échecs que beta.160, aucun nouvel échec dans cette passe. Comparaison dans `build/session161-server-failures.json`. |
|
||||
| Contrôles JVM et persistance fichier/SQL | Réussis, dont `SERVER_METRICS161_PASS` et `COMMUNITY154_SQL_PASS` dans `build/beta161-release.log`. |
|
||||
| Démarrage et raccourci U | `SESSION161_KEYS_PASS` : défaut U, migration unique depuis Tab, choix personnalisé conservé. |
|
||||
| Parcours natif communautaire | `SEARCH159_CLIENT_PASS mode=file` : photos/galerie, FR/EN, GUI 2/3/4, recherche, bulletin en lecture seule, suivi et infobulle du repère. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`. |
|
||||
| Web SQLite et MariaDB isolée | 29 tests, 252 assertions réussies ; lecture seule visiteurs/joueurs/staff, contrats de persistance, statistiques absentes/périmées/arrêtées. Pint et `git diff --check` réussis. |
|
||||
| Navigateur | Accordéon exclusif, lien vers l’article, calendrier sans heure, visage/auteur/date et lecture en bas ; vérification desktop et 390 × 844. |
|
||||
| Assemblage final | `./gradlew build assemblePack :sanctuary-test:exportDuoLaunch -x :sanctuary:check` réussi, après les vérifications ci-dessus. Exclusion ponctuelle des tests connus en échec, pas de suppression d’assertion. |
|
||||
| Laboratoire réel | Monde existant `duo-flat-160` relancé, serveur 768 MiB/client 2048 MiB, shaders désactivés. KokaLab non OP, 26 niveaux conservés. Deux figurants, compteur SQL et Web : 1 humain. Journal de durée persisté et exclusions vérifiées. |
|
||||
|
||||
Le premier démarrage de test a révélé une mauvaise déclaration du nouveau mixin :
|
||||
`PlayerListKeyMixin` au lieu de `client.PlayerListKeyMixin`. Elle est corrigée ;
|
||||
la classe et sa déclaration sont présentes dans le JAR final. Le test natif a
|
||||
ensuite réussi. L’assemblage avait redéclenché une seconde passe de GameTests
|
||||
serveur ; cette répétition a été interrompue, la première passe complète faisant
|
||||
foi. Logs distincts : `beta161-check-build.log`, `beta161-release.log`,
|
||||
`beta161-client.log`, `beta161-assembly.log`, sous `build/`.
|
||||
|
||||
Livraison locale uniquement : `mods/sanctuary/build/libs/sanctuary-beta.161.jar`
|
||||
et pack assemblé dans `build/packwiz`. SHA-256 du JAR :
|
||||
`c6e0424bf091c41827730bc260dad3b95b7c949fb0dcc41811f0ae3d94bce79c`.
|
||||
Les 29 ressources shader sont identiques à beta.160. Les anciens JAR beta.154–160
|
||||
sont conservés. Aucun canal public ni instance Prism personnelle n’a été modifié.
|
||||
Le téléchargement public du site reste l’ancien lien beta.151, hors de cette
|
||||
livraison locale. Les 23 échecs préexistants restent ouverts.
|
||||
|
||||
Le compteur de jours débute à l’activation de cette version : le fonctionnement
|
||||
antérieur n’était pas mesuré. Le site reçoit les nouveaux compteurs au rechargement
|
||||
de la page. La mesure d’une mort réelle supplémentaire n’a pas été provoquée sur
|
||||
le personnage de la séance ; l’import et l’incrément sont couverts par le test du
|
||||
journal et les tests de lecture Web.
|
||||
@@ -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.148
|
||||
pack_version=beta.148
|
||||
mod_version=beta.166
|
||||
pack_version=beta.166
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -16,5 +16,39 @@ processResources {
|
||||
filesMatching('fabric.mod.json') { expand(values) }
|
||||
}
|
||||
loom { runs { client { runDir 'run/quick-test' } } }
|
||||
loom {
|
||||
runs {
|
||||
duoServer {
|
||||
server()
|
||||
name 'Sanctuary duo lab'
|
||||
runDir '../../build/duo/server'
|
||||
vmArg '-Xms256m'
|
||||
vmArg '-Xmx768m'
|
||||
vmArg '-Dsanctuary.duo=true'
|
||||
}
|
||||
duoClient {
|
||||
client()
|
||||
name 'Sanctuary duo client'
|
||||
runDir '../../build/duo/client'
|
||||
vmArg '-Xms512m'
|
||||
vmArg '-Xmx2G'
|
||||
programArgs '--graphicsBackend', 'vulkan', '--username', 'KokaLab', '--width', '1280', '--height', '720'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Export the Loom launcher so play sessions do not retain a Gradle JVM.
|
||||
tasks.register('exportDuoLaunch') {
|
||||
dependsOn tasks.named('runDuoServer').get().taskDependencies.getDependencies(tasks.named('runDuoServer').get())
|
||||
dependsOn tasks.named('runDuoClient').get().taskDependencies.getDependencies(tasks.named('runDuoClient').get())
|
||||
doLast {
|
||||
['server': 'runDuoServer', 'client': 'runDuoClient'].each { kind, taskName ->
|
||||
def launch = tasks.named(taskName).get()
|
||||
def destination = rootProject.layout.buildDirectory.file("duo/${kind}.args").get().asFile
|
||||
destination.parentFile.mkdirs()
|
||||
def arguments = launch.allJvmArgs.findAll { !it.toString().startsWith('@') } + ['-classpath', launch.classpath.asPath, launch.mainClass.get()] + launch.args + launch.argumentProviders.collectMany { it.asArguments().toList() }
|
||||
destination.text = arguments.collect { '"' + it.toString().replace('\\', '\\\\').replace('"', '\\"') + '"' }.join('\n') + '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named('test') { failOnNoDiscoveredTests=false }
|
||||
tasks.named('jar') { from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary_test' } } }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package fr.koka.sanctuarytest;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import fr.koka.sanctuary.operator.OperatorService;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ClientInformation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.CommonListenerCookie;
|
||||
import net.minecraft.world.level.GameType;
|
||||
|
||||
/** Optional development module only. No sockets, renderer or additional JVM per actor. */
|
||||
final class DuoLab {
|
||||
private static final Map<String, ServerPlayer> ACTORS = new LinkedHashMap<>();
|
||||
private static java.io.BufferedWriter trace;
|
||||
private static int ticks;
|
||||
private static long traceStarted;
|
||||
private DuoLab() { }
|
||||
|
||||
static void register() {
|
||||
// An explicit JVM opt-in keeps the ordinary quick-test pack unchanged.
|
||||
if (!Boolean.getBoolean("sanctuary.duo")) return;
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) ->
|
||||
dispatcher.register(Commands.literal("duo").requires(OperatorService::canCommand)
|
||||
.then(Commands.literal("trace")
|
||||
.then(Commands.literal("start").executes(c -> trace(c.getSource(), true)))
|
||||
.then(Commands.literal("stop").executes(c -> trace(c.getSource(), false))))
|
||||
.then(Commands.literal("spawn").then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(c -> spawn(c.getSource(), StringArgumentType.getString(c, "name")))))
|
||||
.then(Commands.literal("remove").then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(c -> remove(c.getSource(), StringArgumentType.getString(c, "name")))))
|
||||
.then(Commands.literal("list").executes(c -> {
|
||||
c.getSource().sendSuccess(() -> Component.translatable("sanctuary_test.duo.list", String.join(", ", ACTORS.keySet())), false);
|
||||
return ACTORS.size();
|
||||
}))));
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||
if (trace != null && System.nanoTime() - traceStarted >= 300_000_000_000L) closeTrace();
|
||||
if (trace == null || ++ticks % 40 != 0) return;
|
||||
try {
|
||||
for (var player : server.getPlayerList().getPlayers()) {
|
||||
var row = new com.google.gson.JsonObject();
|
||||
row.addProperty("time", java.time.Instant.now().toString());
|
||||
row.addProperty("player", player.getGameProfile().name());
|
||||
row.addProperty("actor", ACTORS.containsValue(player));
|
||||
row.addProperty("dimension", player.level().dimension().identifier().toString());
|
||||
row.addProperty("x", player.getX()); row.addProperty("y", player.getY()); row.addProperty("z", player.getZ());
|
||||
row.addProperty("yaw", player.getYRot()); row.addProperty("pitch", player.getXRot());
|
||||
trace.write(row.toString()); trace.newLine();
|
||||
}
|
||||
trace.flush();
|
||||
} catch (java.io.IOException error) {
|
||||
org.slf4j.LoggerFactory.getLogger("sanctuary-duo").error("Trace stopped: cannot write session", error);
|
||||
closeTrace();
|
||||
}
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { ACTORS.clear(); closeTrace(); });
|
||||
}
|
||||
|
||||
private static int trace(CommandSourceStack source, boolean start) {
|
||||
closeTrace();
|
||||
if (start) try {
|
||||
var folder = java.nio.file.Path.of("duo-sessions");
|
||||
java.nio.file.Files.createDirectories(folder);
|
||||
var path = folder.resolve("positions-" + System.currentTimeMillis() + ".jsonl");
|
||||
trace = java.nio.file.Files.newBufferedWriter(path, StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE_NEW);
|
||||
traceStarted = System.nanoTime(); ticks = 0;
|
||||
} catch (java.io.IOException error) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.trace_failed")); return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.trace_" + (start ? "started" : "stopped")), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void closeTrace() {
|
||||
if (trace != null) try { trace.close(); } catch (java.io.IOException ignored) { }
|
||||
trace = null;
|
||||
}
|
||||
|
||||
private static int spawn(CommandSourceStack source, String name) {
|
||||
if (!name.matches("[A-Za-z0-9_]{1,12}") || ACTORS.size() >= 4
|
||||
|| source.getServer().getPlayerList().getPlayerByName("Lab_" + name) != null) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.invalid"));
|
||||
return 0;
|
||||
}
|
||||
String fullName = "Lab_" + name;
|
||||
var profile = new GameProfile(UUID.nameUUIDFromBytes(("SanctuaryDuo:" + fullName).getBytes(StandardCharsets.UTF_8)), fullName);
|
||||
var player = new ServerPlayer(source.getServer(), source.getLevel(), profile, ClientInformation.createDefault());
|
||||
source.getServer().getPlayerList().placeNewPlayer(new SinkConnection(), player, CommonListenerCookie.createInitial(profile, false));
|
||||
var position = source.getPosition();
|
||||
player.teleportTo(source.getLevel(), position.x + 2, position.y, position.z, java.util.Set.of(), 0, 0, false);
|
||||
player.setGameMode(GameType.CREATIVE);
|
||||
ACTORS.put(name, player);
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.spawned", fullName), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int remove(CommandSourceStack source, String name) {
|
||||
var player = ACTORS.remove(name);
|
||||
if (player == null) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.missing", name));
|
||||
return 0;
|
||||
}
|
||||
source.getServer().getPlayerList().remove(player);
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.removed", name), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static final class SinkConnection extends Connection {
|
||||
private net.minecraft.network.PacketListener listener;
|
||||
SinkConnection() { super(PacketFlow.SERVERBOUND); }
|
||||
@Override public <T extends net.minecraft.network.PacketListener> void setupInboundProtocol(net.minecraft.network.ProtocolInfo<T> protocol, T listener) { this.listener = listener; }
|
||||
@Override public net.minecraft.network.PacketListener getPacketListener() { return listener; }
|
||||
@Override public void flushChannel() { }
|
||||
@Override public void send(Packet<?> packet) { }
|
||||
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener) { }
|
||||
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener, boolean flush) { }
|
||||
@Override public boolean isConnected() { return true; }
|
||||
@Override public boolean isConnecting() { return false; }
|
||||
@Override public boolean isMemoryConnection() { return true; }
|
||||
@Override public SocketAddress getRemoteAddress() { return new InetSocketAddress("127.0.0.1", 0); }
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,11 @@ public final class QuickTestMod implements ModInitializer {
|
||||
}
|
||||
public void onInitialize(){
|
||||
QuickTestWorld.enable();
|
||||
DuoLab.register();
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
|
||||
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
|
||||
if(ServerConfigurationNetworking.canSend(handler,Mode.TYPE))
|
||||
ServerConfigurationNetworking.send(handler,new Mode(QuickTestWorld.active(server)));
|
||||
ServerConfigurationNetworking.send(handler,new Mode(QuickTestWorld.active(server) && !Boolean.getBoolean("sanctuary.duo.intro")));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Quick Test"
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Quick Test",
|
||||
"sanctuary_test.duo.list": "Lab actors: %s",
|
||||
"sanctuary_test.duo.invalid": "Name: 1–12 letters/digits/_; up to 4 actors, unique names.",
|
||||
"sanctuary_test.duo.spawned": "%s spawned nearby (test actor).",
|
||||
"sanctuary_test.duo.missing": "Lab actor not found: %s",
|
||||
"sanctuary_test.duo.removed": "Lab actor removed: %s",
|
||||
"sanctuary_test.duo.trace_failed": "Cannot write session log.",
|
||||
"sanctuary_test.duo.trace_started": "Position log started (one sample every 2 seconds).",
|
||||
"sanctuary_test.duo.trace_stopped": "Position log stopped."
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Test rapide"
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Test rapide",
|
||||
"sanctuary_test.duo.list": "Personnages labo : %s",
|
||||
"sanctuary_test.duo.invalid": "Nom : 1–12 lettres/chiffres/_ ; 4 personnages maximum, noms uniques.",
|
||||
"sanctuary_test.duo.spawned": "%s créé à proximité (personnage de test).",
|
||||
"sanctuary_test.duo.missing": "Personnage labo introuvable : %s",
|
||||
"sanctuary_test.duo.removed": "Personnage labo retiré : %s",
|
||||
"sanctuary_test.duo.trace_failed": "Impossible d’écrire le journal de séance.",
|
||||
"sanctuary_test.duo.trace_started": "Journal des positions démarré (une mesure toutes les 2 secondes).",
|
||||
"sanctuary_test.duo.trace_stopped": "Journal des positions arrêté."
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ tasks.named('runGameTest') {
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
def access166Client = providers.gradleProperty('sanctuaryAccess166ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryAccess166ClientTests', access166Client)
|
||||
if (access166Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Access166ClientChecks') }
|
||||
def colored139Client = providers.gradleProperty('sanctuaryColored139ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryColored139ClientTests', colored139Client)
|
||||
if (colored139Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ColoredLights139ClientChecks') }
|
||||
@@ -128,6 +131,12 @@ tasks.named('processGametestResources') {
|
||||
def sunshaft137Client = providers.gradleProperty('sanctuarySunshaft137ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySunshaft137ClientTests', sunshaft137Client)
|
||||
if (sunshaft137Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Sunshafts137ClientChecks') }
|
||||
def defaults160 = providers.gradleProperty('sanctuaryDefaults160ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('defaults160', defaults160)
|
||||
if (defaults160) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ShaderDefaults160ClientChecks') }
|
||||
def session161 = providers.gradleProperty('sanctuarySession161ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySession161ClientTests', session161)
|
||||
if (session161) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Session161ClientChecks') }
|
||||
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
|
||||
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
|
||||
@@ -258,6 +267,18 @@ 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 cards158Client = providers.gradleProperty('sanctuaryCards158ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryCards158ClientTests', cards158Client)
|
||||
if (cards158Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community158ClientChecks') }
|
||||
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 +508,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')
|
||||
@@ -1142,6 +1165,14 @@ tasks.register('intro031Smoke', JavaExec) {
|
||||
}
|
||||
tasks.named('check') { dependsOn('intro031Smoke') }
|
||||
|
||||
tasks.register('access144Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.progression.AccessCodeSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('access144Smoke') }
|
||||
|
||||
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
|
||||
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
|
||||
}
|
||||
@@ -1211,3 +1242,35 @@ 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'))
|
||||
}
|
||||
|
||||
tasks.register('serverMetrics161Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.community.ServerMetricsSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('serverMetrics161Smoke') }
|
||||
|
||||
// Explicit disposable web fixture only; not part of ordinary offline checks.
|
||||
tasks.register('access166Interop', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.progression.AccessInterop'
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import fr.koka.sanctuary.progression.*;
|
||||
import java.nio.file.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
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.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Real configuration handshake against a controllable HTTP endpoint; PHP is checked separately. */
|
||||
public final class Access166ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
Path config=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary/access.json");
|
||||
byte[] before;
|
||||
HttpServer api;
|
||||
var consumed=new AtomicBoolean();var attempts=new AtomicInteger();var retries=new AtomicInteger();
|
||||
var running=new AtomicBoolean(true);var filled=new AtomicBoolean();
|
||||
var welcome=new AtomicReference<HelloWorldScreen>();
|
||||
try {
|
||||
before=Files.exists(config)?Files.readAllBytes(config):null;
|
||||
api=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);
|
||||
api.createContext("/api/v1/access-codes/",exchange->{
|
||||
try {
|
||||
var input=JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(),java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
boolean redeem=exchange.getRequestURI().getPath().endsWith("redeem");
|
||||
if(redeem && attempts.incrementAndGet()==1){
|
||||
consumed.set(true);exchange.sendResponseHeaders(503,-1);return;
|
||||
}
|
||||
var output=new JsonObject();output.addProperty("valid",true);
|
||||
output.addProperty("reason",consumed.get()?"resumed":redeem?"redeemed":"unused");
|
||||
output.add("minecraft_username",input.get("minecraft_username"));output.add("admission_id",input.get("admission_id"));output.addProperty("discord_id","166001");
|
||||
byte[] bytes=output.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type","application/json");exchange.sendResponseHeaders(200,bytes.length);exchange.getResponseBody().write(bytes);
|
||||
} finally {exchange.close();}
|
||||
});
|
||||
api.start();Files.createDirectories(config.getParent());
|
||||
Files.writeString(config,"{\"appUrl\":\"http://127.0.0.1:"+api.getAddress().getPort()+"\",\"token\":\"test-only\"}");
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{
|
||||
if(!running.get()||!(m.gui.screen() instanceof HelloWorldScreen screen))return;
|
||||
if(!filled.getAndSet(true)){
|
||||
welcome.set(screen);
|
||||
check(screen.children().stream().anyMatch(child->child instanceof Button b && b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.get_code")),"Registration link is offered by configured server");
|
||||
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());
|
||||
for(var child:screen.children())if(child instanceof EditBox box && box.getMessage().getContents() instanceof TranslatableContents text){
|
||||
if(text.getKey().equals("sanctuary.starter.name"))box.setValue("Echo");
|
||||
if(text.getKey().equals("sanctuary.progression.code"))box.setValue("SANC-ABCD-EFGH");
|
||||
}
|
||||
}
|
||||
for(var child:screen.children())if(child instanceof Button button && button.active && button.getMessage().getContents() instanceof TranslatableContents text){
|
||||
if(text.getKey().equals("sanctuary.progression.retry_code")){retries.incrementAndGet();button.onPress(enter());break;}
|
||||
if(text.getKey().equals("sanctuary.progression.enter")){button.onPress(enter());break;}
|
||||
}
|
||||
});
|
||||
c.runOnClient(m->{m.options.renderDistance().set(4);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{
|
||||
s.setName("Access166 "+UUID.randomUUID());s.setSeed("166");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,1200);
|
||||
check(attempts.get()==2&&retries.get()==1,"Lost response retried once through real HelloWorldScreen");
|
||||
world.getServer().runOnServer(server->{
|
||||
try {
|
||||
Path data=server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data");
|
||||
var ledger=JsonParser.parseString(Files.readString(data.resolve("sanctuary-access.json"))).getAsJsonObject();
|
||||
check(ledger.getAsJsonObject("bound").size()==1&&ledger.getAsJsonObject("pending").size()==0,"Exactly one committed admission");
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
});
|
||||
c.takeScreenshot("access166-admitted");
|
||||
running.set(false);
|
||||
for(String locale:List.of("fr_fr","en_us")) {
|
||||
c.runOnClient(m->{
|
||||
m.options.guiScale().set(2);m.resizeGui();
|
||||
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
m.gui.setScreen(welcome.get());
|
||||
for(var child:welcome.get().children())if(child instanceof AbstractWidget widget)
|
||||
check(widget.getY()>=0&&widget.getY()+widget.getHeight()<=welcome.get().height,"All welcome controls fit the small GUI");
|
||||
});
|
||||
c.waitTicks(2);c.takeScreenshot("access166-welcome-"+locale);
|
||||
}
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("ACCESS166_CLIENT_PASS: Vulkan, native welcome, lost redeem response, retry verification, single bound admission");
|
||||
} finally {
|
||||
running.set(false);api.stop(0);
|
||||
try {if(before==null)Files.deleteIfExists(config);else Files.write(config,before);}catch(Exception error){throw new AssertionError(error);}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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); }
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
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 Community158ClientChecks 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);
|
||||
}});
|
||||
var noticeId=UUID.randomUUID().toString();
|
||||
net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STARTED.register(server->{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(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
var author=new Community.Actor(UUID.randomUUID().toString(),"Voisin",true);
|
||||
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Annonce officielle159","Le serveur vous souhaite la bienvenue.",0));
|
||||
for(int i=0;i<13;i++) {
|
||||
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","article","info","Gazette "+i,"Le village raconte. ".repeat(90)+(i==0?" Unique159":""),0,testPhoto()));
|
||||
store.change(author,new Community.Change("create",i==0?noticeId:UUID.randomUUID().toString(),"","notice",List.of("info","event","work","need").get(i%4),"Demande "+i,"Rendez-vous au village. ".repeat(40),0,null,i==0?new CommunityTask(new CommunityTask.Location("minecraft:overworld",0,64,0),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"shared","custom",List.of(),"Un repas au village"):CommunityTask.EMPTY));
|
||||
}
|
||||
}catch(Exception error){throw new AssertionError(error);}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cards158 "+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->{
|
||||
var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().invulnerable=true;player.setExperienceLevels(20);
|
||||
check(fr.koka.sanctuary.progression.ProgressionService.purchase(player,"atlas",fr.koka.sanctuary.progression.ProgressEffects.get(player).revision()),"Atlas unlocked through progression");
|
||||
fr.koka.sanctuary.progression.ProgressionService.send(player,"");
|
||||
for(int z=-2;z<=2;z++)for(int x=-2;x<=2;x++) {
|
||||
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(player.level(),player.chunkPosition().x()+x,player.chunkPosition().z()+z);
|
||||
if(tile!=null)fr.koka.sanctuary.atlas.AtlasService.observations(player).observe(tile);
|
||||
}
|
||||
});
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(int windowWidth:List.of(1280,1920)) {
|
||||
c.runOnClient(m->{
|
||||
m.getWindow().setWindowed(windowWidth,windowWidth==1280?960:1080);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();
|
||||
if(a.maxScrollAmount()>0){s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);}
|
||||
var photos=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).toList();
|
||||
check(photos.stream().mapToInt(AbstractWidget::getRight).max().orElseThrow()>s.width*0.8,"Gallery uses GUI width");
|
||||
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("search159-gallery-"+locale+"-"+scale+"-"+windowWidth+(database?"-database":"-file"));
|
||||
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
|
||||
c.runOnClient(m->{var s=m.gui.screen();var image=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow();check(image.getWidth()>s.width*0.8&&image.getHeight()>s.height*0.5,"Preview fills available GUI");check(button(s,"photo_assign").getBottom()<s.height-30,"Preview controls remain visible");});
|
||||
c.takeScreenshot("search159-preview-"+locale+"-"+scale+"-"+windowWidth);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.screenshots_back"));
|
||||
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.getWindow().setWindowed(1280,960);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(),"previous").active,500);
|
||||
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
|
||||
c.takeScreenshot("cards158-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->{var article=widgets(m.gui.screen()).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();var face=widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).findFirst().orElseThrow();check(face.getY()>=article.getBottom(),"Author metadata below the article");check(button(m.gui.screen(),"actions").getY()==face.getY(),"Author metadata next to ellipsis");check(widgets(m.gui.screen()).anyMatch(w->w instanceof AbstractWidget widget && widget.getMessage().getString().contains("/"+java.time.Year.now()+" ")),"Article date includes year");});
|
||||
articleSceneChecks(c);
|
||||
c.takeScreenshot("cards158-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()==10&&posts.getFirst().title().equals("Une journée au village"),"Newest publication among seeded cards");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
|
||||
}catch(Exception e){throw new AssertionError(e);}});
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(String kind:List.of("article","notice")){
|
||||
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());CommunityClient.open(new PauseScreen(true),kind,"",false);});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{var screen=m.gui.screen();var cards=widgets(screen).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).toList();
|
||||
check(cards.get(0).getY()==cards.get(1).getY()&&cards.get(0).getRight()<cards.get(1).getX(),"Two-column grid");
|
||||
check(cards.get(2).getY()>cards.get(0).getY(),"Rows continue vertically");
|
||||
check(!has(screen,"sanctuary.community.link")&&has(screen,"sanctuary.community.server_tab"),"Public navigation restores read-only server history");
|
||||
check(widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Grid scrolls");
|
||||
});
|
||||
c.takeScreenshot("cards158-grid-"+kind+"-"+locale+"-"+scale+(database?"-database":"-file"));
|
||||
}
|
||||
c.runOnClient(m->{var a=widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();a.setScrollAmount(a.maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.scrollAmount()>0),"Appending cards retains scroll"));
|
||||
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"article","",false);edit(m.gui.screen()).setValue("unique159");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 0"),"Search finds body beyond first page");edit(m.gui.screen()).setValue("VoISin");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.more"));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{edit(m.gui.screen()).setValue("absent159");m.gui.screen().setFocused(edit(m.gui.screen()));for(int tick=0;tick<12;tick++)m.gui.screen().tick();check(CommunityClient.pending(),"Old search is in flight");edit(m.gui.screen()).setValue("Gazette 12");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 12"),"Latest search wins over prior pending request");check(edit(m.gui.screen()).isFocused(),"Search retains keyboard focus");});
|
||||
c.takeScreenshot("search159-filtered");
|
||||
c.runOnClient(m->edit(m.gui.screen()).setValue(""));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.server_tab"));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public server listing is read only");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Public bulletin has no write actions");check(text(m.gui.screen(),"Annonce officielle159"),"Server bulletin readable by ordinary player");});
|
||||
c.takeScreenshot("search159-server-readonly");
|
||||
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();inventory.setItem(6,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.OAK_LOG,17));inventory.setItem(7,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,3));});
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Le coffre descriptif");body(m.gui.screen()).setValue("Du bois pour le pont");press(m.gui.screen(),"sanctuary.community.task_configure");press(m.gui.screen(),"sanctuary.community.location_current");press(m.gui.screen(),"sanctuary.community.materials");edit(m.gui.screen()).setValue("minecraft:oak_log");});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{var screen=m.gui.screen();var slot=widgets(screen).filter(CommunityMaterialsScreen.Slot.class::isInstance).map(CommunityMaterialsScreen.Slot.class::cast).filter(w->w.getMessage().getString().startsWith("minecraft:oak_log")).findFirst().orElseThrow();slot.onPress(enter());var boxes=widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).toList();boxes.getLast().setValue("0");check(!button(m.gui.screen(),"save").active,"Invalid quantity cannot be saved");boxes.getLast().setValue("128");check(button(m.gui.screen(),"save").active,"Valid quantity restores save");});
|
||||
c.takeScreenshot("cards158-materials");
|
||||
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.audience.shared");press(m.gui.screen(),"sanctuary.community.reward.none");press(m.gui.screen(),"sanctuary.community.reward.items");edit(m.gui.screen()).setValue("Un repas au village");press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
|
||||
c.takeScreenshot("cards158-notice-details");
|
||||
noticeSceneChecks(c);
|
||||
|
||||
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();check(inventory.getItem(6).getCount()==17&&inventory.getItem(6).is(net.minecraft.world.item.Items.OAK_LOG)&&inventory.getItem(7).getCount()==3&&inventory.getItem(7).is(net.minecraft.world.item.Items.DIAMOND),"Descriptive chest preserves real inventory");});
|
||||
c.runOnClient(m->{check(text(m.gui.screen(),"Un repas au village"),"Custom reward round trip");CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);c.waitTicks(25);
|
||||
c.runOnClient(m->{NotificationClient.options().pinnedAdvancements=true;press(m.gui.screen(),"sanctuary.community.follow");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->check(NotificationClient.tracked().stream().anyMatch(t->t.title().getString().equals("Demande 0")),"Server-backed quest appears in HUD"));
|
||||
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);c.takeScreenshot("cards158-follow-hud");
|
||||
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.map_show")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.map_show"));
|
||||
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&!a.questPins().isEmpty(),200);
|
||||
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Followed location appears on atlas");check(CommunityMapMarkers.project("minecraft:the_nether",x->100,z->100,0,0,300,300).isEmpty(),"Other dimension has no pin");
|
||||
var tooltip=new StringBuilder();for(var line:CommunityMapMarkers.tooltip(pin.marker())){line.accept((index,style,point)->{tooltip.appendCodePoint(point);return true;});tooltip.append(' ');}
|
||||
check(tooltip.toString().contains("Voisin")&&tooltip.toString().contains("128")&&tooltip.toString().contains("Un repas")&&tooltip.toString().contains("Rendez-vous"),"Hover includes author, summary, materials and reward");
|
||||
hover(m,pin.x(),pin.y());
|
||||
});
|
||||
c.waitTicks(3);c.takeScreenshot("search159-map-hover"+(database?"-database":"-file"));
|
||||
for(int scale:List.of(3,4)) {
|
||||
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();var pin=((SanctuaryAtlasScreen)m.gui.screen()).questPins().getFirst();hover(m,pin.x(),pin.y());});
|
||||
c.waitTicks(3);c.takeScreenshot("search159-map-hover-gui"+scale);
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
|
||||
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(a.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false),"Quest marker click handled");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
|
||||
world.getServer().runOnServer(server->server.getPlayerList().getPlayers().getFirst().teleportTo(20.5,65,20.5));
|
||||
c.waitTicks(5);c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(20);
|
||||
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Pause map includes followed location");hover(m,pin.x(),pin.y());});
|
||||
c.waitTicks(3);c.takeScreenshot("search159-pause-map-hover"+(database?"-database":"-file"));
|
||||
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();map.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);c.waitTicks(25);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.unfollow"));
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);
|
||||
c.runOnClient(m->check(CommunityMapMarkers.project("minecraft:overworld",x->100,z->100,0,0,300,300).isEmpty(),"Unfollowing removes the map marker immediately"));
|
||||
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
c.runOnClient(m->m.getConnection().sendCommand("sanctuary community admin"));c.waitTicks(5);
|
||||
c.runOnClient(m->check(m.gui.screen()==null,"Non-operator cannot open administration"));
|
||||
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new WorldOptionsScreen(null,m.level));});c.waitTicks(3);
|
||||
c.runOnClient(m->{widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith("Allow Commands")).findFirst().orElseThrow().onPress(enter());widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().equals("Apply Changes")).findFirst().orElseThrow().onPress(enter());});
|
||||
c.waitFor(m->ProgressionClient.operator,150);c.runOnClient(m->{m.gui.setScreen(null);m.getConnection().sendCommand("sanctuary community admin");});
|
||||
c.waitFor(m->m.gui.screen() instanceof CommunityScreen&&has(m.gui.screen(),"sanctuary.community.publish")&&!CommunityClient.pending(),200);
|
||||
c.waitTicks(25); // Respect the server write cooldown after the preceding unfollow.
|
||||
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.publish");edit(m.gui.screen()).setValue("Message officiel");body(m.gui.screen()).setValue("Bienvenue !");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");CommunityClient.open(null,"bulletin","",false);});
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==2&&!CommunityClient.pending(),250);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public bulletin remains read only for operators");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
|
||||
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions"),"Operator uses administration to edit official messages");m.gui.setScreen(new PauseScreen(true));});
|
||||
c.waitFor(m->CommunityClient.overview.has("bulletins")&&!CommunityClient.pending(),200);
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
|
||||
c.waitTicks(20);
|
||||
c.runOnClient(m->{
|
||||
var date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().contains(java.time.Year.now().toString())).findFirst().orElseThrow();
|
||||
var official=widgets(m.gui.screen()).filter(PauseTextButton.class::isInstance).map(PauseTextButton.class::cast).filter(w->w.getMessage().getString().contains("Message officiel")).findFirst().orElseThrow();
|
||||
check(date.getRight()<official.getX(),"Full date and server message do not overlap");
|
||||
var screen=m.gui.screen();
|
||||
check(date.getX()==8&&date.getHeight()==38&&date.getY()==3,"Calendar framed at top left");
|
||||
check(!has(screen,"sanctuary.progression.history"),"World navigation removed");
|
||||
var discovery=pauseButton(screen,"sanctuary.progression.blocks");var progression=pauseButton(screen,"sanctuary.progression.progress");var inhabitant=pauseButton(screen,"sanctuary.progression.identity");
|
||||
check(date.getWidth()==discovery.getWidth(),"Calendar matches navigation width");
|
||||
check(official.getX()==date.getRight()+10&&official.getRight()==screen.width-8,"Bulletin spans map and community panels");
|
||||
check(date.getMessage().getString().matches(".*\\d{2}:\\d{2}$"),"Clock included in calendar");
|
||||
check(inhabitant.getY()-progression.getBottom()>progression.getY()-discovery.getBottom(),"Personal and social navigation groups separated");
|
||||
var navigation=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=50&&b.getY()<pauseButton(screen,"menu.options").getY()).toList();
|
||||
check(navigation.size()==5,"Five navigation destinations");
|
||||
check(navigation.stream().allMatch(b->b.getBottom()<pauseButton(screen,"menu.options").getY()),"Navigation clears settings");
|
||||
var resume=pauseButton(screen,"menu.returnToGame");
|
||||
check(Math.abs(resume.getX()+resume.getWidth()/2-screen.width/2)<=1&&resume.getY()>screen.height-33,"Resume centered in footer");
|
||||
check(pauseButton(screen,"options.worldOptions.button").getBottom()<screen.height-39,"World settings remain in body");
|
||||
for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Pause widget within viewport");
|
||||
|
||||
});
|
||||
c.takeScreenshot("pause164-calendar-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("PAUSE164_LAYOUT_PASS");
|
||||
System.out.println("SEARCH159_CLIENT_PASS mode="+(database?"database":"file"));
|
||||
} finally {intro.set(false);}
|
||||
}
|
||||
private static void articleSceneChecks(ClientGameTestContext c) {
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();var article=widgets(s).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();
|
||||
check(button(s,"back").getBottom()<=article.getY(),"Gazette toolbar fixed above article");
|
||||
if(s.width>=522) {
|
||||
check(body(s).getX()>=article.getRight(),"Gazette conversation on the right");
|
||||
check(button(s,"reply").getBottom()<=s.height-33,"Gazette composer above footer");
|
||||
} else {
|
||||
check(body(s).getY()>article.getBottom(),"Narrow Gazette stacks conversation below article");
|
||||
check(widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Narrow article and conversation scroll");
|
||||
}
|
||||
body(s).setValue("Brouillon Gazette165");
|
||||
});
|
||||
c.takeScreenshot("gazette165-article-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();check(body(m.gui.screen()).getValue().equals("Brouillon Gazette165"),"Gazette draft survives resize");body(m.gui.screen()).setValue("Réponse Gazette165");press(m.gui.screen(),"sanctuary.community.reply");});
|
||||
c.waitFor(m->!CommunityClient.pending()&&widgets(m.gui.screen()).filter(CommunityBubble.class::isInstance).map(CommunityBubble.class::cast).anyMatch(w->w.getMessage().getString().equals("Réponse Gazette165")),300);
|
||||
c.takeScreenshot("gazette165-conversation");
|
||||
System.out.println("GAZETTE165_SCENE_PASS");
|
||||
}
|
||||
private static void noticeSceneChecks(ClientGameTestContext c) {
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();var scene=widgets(s).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();
|
||||
check(button(s,"back").getBottom()<=scene.getY(),"Back/refresh fixed above scene");
|
||||
if(s.width>=522) {
|
||||
check(body(s).getX()>=scene.getRight(),"Conversation to the right of author scene");
|
||||
check(button(s,"reply").getBottom()<=s.height-33,"Reply stays above footer");
|
||||
} else check(body(s).getY()>scene.getBottom(),"Narrow GUI stacks conversation below scene");
|
||||
body(s).setValue("Brouillon scène162");hover(m,0,0);
|
||||
});
|
||||
c.waitTicks(3);
|
||||
var left=new java.util.concurrent.atomic.AtomicReference<Float>();
|
||||
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();left.set(scene.yaw());hover(m,m.gui.screen().width,m.gui.screen().height);});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();check(scene.yaw()>left.get()&&Math.abs(scene.pitch())<50,"Portrait tracks pointer within bounded angles");});
|
||||
c.takeScreenshot("notice162-scene-"+locale+"-"+scale);
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();check(body(m.gui.screen()).getValue().equals("Brouillon scène162"),"Draft survives GUI resize");body(m.gui.screen()).setValue("Réponse scène162");press(m.gui.screen(),"sanctuary.community.reply");});
|
||||
c.waitFor(m->!CommunityClient.pending()&&widgets(m.gui.screen()).filter(CommunityBubble.class::isInstance).map(CommunityBubble.class::cast).anyMatch(w->w.getMessage().getString().equals("Réponse scène162")),300);
|
||||
c.takeScreenshot("notice162-conversation");
|
||||
System.out.println("NOTICE162_SCENE_PASS");
|
||||
}
|
||||
private static void hover(net.minecraft.client.Minecraft m,double x,double y) {
|
||||
var w=m.getWindow();double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
|
||||
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();
|
||||
}
|
||||
private static Button pauseButton(Screen screen,String key) {return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
|
||||
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();}
|
||||
}
|
||||
+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.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()>=50&&b.getY()<nativeButton(s,"menu.options").getY()).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
|
||||
check(navigation.size()==5&&!has(s,"sanctuary.progression.history"),"Five real navigation buttons without World");
|
||||
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()>=(i==2?8:3),"Two spaced navigation groups");
|
||||
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-39,"Options and quit stacked at bottom left");
|
||||
var feed=areas(s).getFirst();
|
||||
check(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getY()>s.height-33 && areas(s).getLast().getBottom()<resume.getY(),"Resume centered in native footer");
|
||||
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(CommunityArticle.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();}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import java.nio.file.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
public final class Session161ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
c.runOnClient(m->{
|
||||
var marker=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
var saved=m.options.keyPlayerList.saveString();
|
||||
try {
|
||||
byte[] previous=Files.exists(marker)?Files.readAllBytes(marker):null;
|
||||
try {
|
||||
check(m.options.keyPlayerList.getDefaultKey().getValue()==InputConstants.KEY_U,"U is reset-to-default player list key");
|
||||
Files.deleteIfExists(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U)),"Saved Tab migrates to U");
|
||||
Files.delete(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8)),"Custom binding retained");
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB)),"Migration happens once");
|
||||
System.out.println("SESSION161_KEYS_PASS");
|
||||
} finally {
|
||||
if(previous==null)Files.deleteIfExists(marker);else Files.write(marker,previous);
|
||||
m.options.keyPlayerList.setKey(InputConstants.getKey(saved));net.minecraft.client.KeyMapping.resetMapping();m.options.save();
|
||||
}
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
});
|
||||
new Community158ClientChecks().runTest(c);
|
||||
}
|
||||
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
|
||||
}
|
||||
+80
-4
@@ -50,10 +50,78 @@ 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));
|
||||
@@ -82,6 +150,11 @@ 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{
|
||||
@@ -91,21 +164,24 @@ public final class Pbr134ClientChecks implements FabricClientGameTest {
|
||||
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,256}){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");
|
||||
|
||||
+41
-2
@@ -46,10 +46,17 @@ final class PixelReflectionsClientChecks {
|
||||
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,256,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));
|
||||
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"));
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -82,6 +89,37 @@ final class PixelReflectionsClientChecks {
|
||||
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);
|
||||
@@ -221,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
@@ -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()
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
/** Exercise the real preference loader in the isolated native client. */
|
||||
public final class ShaderDefaults160ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
context.runOnClient(client -> {
|
||||
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
|
||||
try {
|
||||
byte[] previous = Files.exists(path) ? Files.readAllBytes(path) : null;
|
||||
var cache = VanillaLight.class.getDeclaredField("preferences");
|
||||
cache.setAccessible(true);
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
cache.set(null, null);
|
||||
check(!VanillaLight.enabled(), "Fresh install disables shaders");
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, "{}"); cache.set(null, null);
|
||||
check(!VanillaLight.enabled(), "Missing setting uses OFF");
|
||||
Files.writeString(path, "{\"enabled\":true,\"pbrIntensity\":73}"); cache.set(null, null);
|
||||
check(VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "Explicit ON and tuning retained");
|
||||
VanillaLight.setEnabled(false); cache.set(null, null);
|
||||
check(!VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "OFF persists without losing tuning");
|
||||
VanillaLight.setEnabled(true); cache.set(null, null);
|
||||
check(VanillaLight.enabled(), "Manual reactivation persists");
|
||||
System.out.println("SHADER160_DEFAULTS_PASS");
|
||||
} finally {
|
||||
if (previous == null) Files.deleteIfExists(path); else Files.write(path, previous);
|
||||
cache.set(null, null);
|
||||
}
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
context.takeScreenshot("shader160-native-client");
|
||||
}
|
||||
private static void check(boolean value, String message) { if (!value) throw new AssertionError(message); }
|
||||
}
|
||||
+5
-1
@@ -83,7 +83,7 @@ public final class Accessory018GameTests {
|
||||
var p=player(h,0);platform(h,p.blockPosition());AccessoryAccess.of(p).setItem(0,new ItemStack(Items.CREEPER_SPAWN_EGG));
|
||||
FamiliarService.tick(p);var pet=FamiliarService.active(p);h.assertTrue(pet!=null,"Dedicated moving familiar spawns");
|
||||
h.assertTrue(!pet.isNoAi() && pet.getTarget()==null && !pet.shouldBeSaved(),"Navigation runs without destructive mob AI or chunk persistence");
|
||||
float health=pet.getHealth();h.assertTrue(!pet.hurtServer(h.getLevel(),p.damageSources().genericKill(),10000) && pet.getHealth()==health,"Even bypass damage cannot injure familiar");
|
||||
float health=pet.getHealth();h.assertTrue(!pet.hurtServer(h.getLevel(),p.damageSources().playerAttack(p),1) && pet.getHealth()==health,"Friendly owner damage cannot injure familiar");
|
||||
h.assertTrue(!pet.isPushable() && !pet.canBeCollidedWith(p) && !pet.canCollideWith(p),"No entity collision or pushing");
|
||||
var pos=pet.position();pet.push(p);h.assertTrue(pet.position().equals(pos),"Contact does not shove pet");
|
||||
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.LEAD));
|
||||
@@ -92,6 +92,10 @@ public final class Accessory018GameTests {
|
||||
h.assertTrue(pet.teleportNearOwner() && h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Safe fallback exits a wall into free supported space");
|
||||
var saved=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());h.assertTrue(!pet.save(saved),"Temporary pet is excluded from saves");
|
||||
FamiliarService.tick(p);h.assertTrue(FamiliarService.active(p)==pet,"Repeated service tick never duplicates pet");
|
||||
var eggBefore=AccessoryAccess.of(p).getItem(0).copy();
|
||||
h.assertTrue(pet.hurtServer(h.getLevel(),p.damageSources().genericKill(),10000),"Non-friendly bypass damage enters the current combat system");
|
||||
h.assertTrue(fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).health()==0 && fr.koka.sanctuary.familiar.FamiliarBattle.bond(p).knockedOutUntil()>fr.koka.sanctuary.companion.CompanionService.now(),"Lethal damage produces a timed KO");
|
||||
h.assertTrue(ItemStack.matches(eggBefore,AccessoryAccess.of(p).getItem(0)),"KO preserves the equipped egg and its identity");
|
||||
AccessoryAccess.of(p).setItem(0,ItemStack.EMPTY);FamiliarService.tick(p);h.assertTrue(pet.isRemoved() && FamiliarService.active(p)==null,"Removing egg removes familiar");h.succeed();
|
||||
}
|
||||
@GameTest public void familiarWalksAroundObstacle(GameTestHelper h) {
|
||||
|
||||
+1
@@ -376,6 +376,7 @@ public final class BlockKnowledgeGameTests {
|
||||
}
|
||||
|
||||
private static boolean place(ServerPlayer player, Item item, BlockPos support, Direction face) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||
var hit = new BlockHitResult(Vec3.atCenterOf(support), face, support, false);
|
||||
var context = new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit));
|
||||
|
||||
+12
-3
@@ -108,8 +108,7 @@ public final class Building009GameTests {
|
||||
var origin=fixture(h,Direction.UP,1);
|
||||
for(var pos:BuildPlane.positions(origin,Direction.UP,Direction.SOUTH,1))
|
||||
h.getLevel().setBlockAndUpdate(pos,Blocks.STONE_SLAB.defaultBlockState());
|
||||
var p=player(h,1,Items.STONE_SLAB,16);aim(p,origin,Direction.UP);
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atLowerCornerOf(origin).add(.5,.5,.5));
|
||||
var p=player(h,1,Items.STONE_SLAB,16);aimInsetTop(h,p,origin,.5);
|
||||
start(h,p,origin);finish(h,p);
|
||||
h.assertTrue(p.getMainHandItem().getCount()==7 && h.getLevel().getBlockState(origin).getValue(SlabBlock.TYPE)==SlabType.DOUBLE,"Existing slabs merge through native contexts and consume one item each");
|
||||
BuildingManager.forget(p);
|
||||
@@ -166,7 +165,7 @@ public final class Building009GameTests {
|
||||
var origin=fixture(h,Direction.UP,1);h.getLevel().setBlockAndUpdate(origin,Blocks.CHEST.defaultBlockState());
|
||||
var chest=(net.minecraft.world.level.block.entity.ChestBlockEntity)h.getLevel().getBlockEntity(origin);
|
||||
chest.setItem(0,new ItemStack(Items.DIAMOND,3));
|
||||
var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
|
||||
var p=player(h,1,Items.COBBLESTONE,16);aimInsetTop(h,p,origin,14.0/16);
|
||||
start(h,p,origin);finish(h,p);
|
||||
h.assertTrue(chest.getItem(0).getCount()==3 && p.containerMenu==p.inventoryMenu,"Building on a container never opens it or changes its contents");
|
||||
BuildingManager.forget(p);
|
||||
@@ -220,6 +219,16 @@ public final class Building009GameTests {
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atCenterOf(origin).add(face.getStepX()*.5,face.getStepY()*.5,face.getStepZ()*.5));
|
||||
p.setOnGround(true);
|
||||
}
|
||||
private static void aimInsetTop(GameTestHelper h,ServerPlayer p,BlockPos origin,double top) {
|
||||
// Rank 1 reaches 3.5 blocks: keep inset surfaces comfortably within that range.
|
||||
// Stay outside the 3x3 placement footprint so the player does not exclude a target.
|
||||
p.setPos(origin.getX()+.5,origin.getY()+1,origin.getZ()-1.5);
|
||||
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atLowerCornerOf(origin).add(.5,top,.5));
|
||||
p.setOnGround(true);
|
||||
var hit=BuildSelection.aimed(p,origin);
|
||||
h.assertTrue(hit!=null && hit.getDirection()==Direction.UP,
|
||||
"Fixture ray must reach the intended inset top before testing grouped placement");
|
||||
}
|
||||
private static void start(GameTestHelper h,ServerPlayer p,BlockPos origin) {
|
||||
BuildingManager.handle(p,new BuildingPayload(1,true,origin,ProgressEffects.get(p).building()));
|
||||
h.assertTrue(BuildingManager.active(p),"Valid native target starts a build session");
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ public final class Collections035GameTests {
|
||||
}
|
||||
var actual=new HashSet<String>();for(var recipe:server.getRecipeManager().getRecipes())if(recipe.id().identifier().getNamespace().equals("minecraft"))actual.add(recipe.id().identifier().toString());
|
||||
check(h,primary.size()==2042&&primary.equals(actual),"All 2042 loaded vanilla recipes classified exactly once");
|
||||
check(h,itemCount==1658&&blockCount==1286&&members.size()==1815,"Complete document inventory uses real registry IDs");h.succeed();
|
||||
check(h,itemCount==1780&&blockCount==1374&&members.size()==1937,"Complete document inventory uses real registry IDs");h.succeed();
|
||||
}
|
||||
@GameTest public void everyAdvancementTeachesItsCollectionsWithoutIngredients(GameTestHelper h){
|
||||
var server=h.getLevel().getServer();var entries=RecipeCollections.entries(server);var all=new TreeSet<String>();entries.forEach(e->all.addAll(e.advancements()));
|
||||
|
||||
+22
-9
@@ -43,7 +43,7 @@ public final class Companion019GameTests {
|
||||
h.assertTrue(p.getAttached(CompanionService.RECORD).equals(bad),"Future data is not silently reset");h.succeed();
|
||||
}
|
||||
@GameTest public void switchingEggRemovesPassiveAndActive(GameTestHelper h){
|
||||
var p=player(h,CompanionType.CAT);CompanionPassives.tick(p);
|
||||
var p=player(h,CompanionType.CAT);equipForWork(p,CompanionType.CAT);CompanionPassives.tick(p);
|
||||
h.assertTrue(Math.abs(p.getAttributeValue(Attributes.FALL_DAMAGE_MULTIPLIER)-.8)<.001,"Cat applies native fall attribute");
|
||||
CompanionEffects.add(p,p,CompanionEffects.Kind.DEFENCE,100,.25F);
|
||||
AccessoryAccess.of(p).setItem(0,new ItemStack(Items.ZOMBIE_SPAWN_EGG));CompanionService.tick(p);
|
||||
@@ -51,9 +51,9 @@ public final class Companion019GameTests {
|
||||
h.assertTrue(CompanionService.passive(p)==null,"New egg has its ten-second attunement");h.succeed();
|
||||
}
|
||||
@GameTest public void poisonDurationAndMilkUseNativeHooks(GameTestHelper h){
|
||||
var p=player(h,CompanionType.CAVE_SPIDER);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));
|
||||
var p=player(h,CompanionType.CAVE_SPIDER);equipForWork(p,CompanionType.CAVE_SPIDER);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));
|
||||
h.assertTrue(p.getEffect(MobEffects.POISON).getDuration()==80,"New poison is shortened by native addEffect hook");
|
||||
equip(p,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.SPEED,300,0));
|
||||
equipForWork(p,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.SPEED,300,0));
|
||||
CompanionPassives.consume(new ItemStack(Items.MILK_BUCKET),p::removeAllEffects);
|
||||
h.assertTrue(p.hasEffect(MobEffects.SPEED)&&!p.hasEffect(MobEffects.POISON),"Selective milk preserves beneficial effects while native clear is called");h.succeed();
|
||||
}
|
||||
@@ -103,16 +103,19 @@ public final class Companion019GameTests {
|
||||
}
|
||||
@GameTest public void creatureProfilesActuallyMove(GameTestHelper h){
|
||||
var p=player(h,CompanionType.ENDER_DRAGON);var pet=FamiliarService.active(p);var start=pet.position();
|
||||
for(int i=0;i<120;i++)pet.tick();
|
||||
int initialTicks=pet.tickCount;
|
||||
// Native 26.3 commonTick advances the entity clock before tick; calling tick alone freezes roaming.
|
||||
for(int i=0;i<120;i++)h.getLevel().tickNonPassenger(pet);
|
||||
h.assertTrue(pet.tickCount==initialTicks+120,"Native entity cycle advances the familiar clock");
|
||||
h.assertTrue(pet.isNoGravity()&&pet.motion()==FamiliarMotion.DRAGON,"Dragon uses aerial navigation");
|
||||
h.assertTrue(pet.position().distanceToSqr(start)>1&&pet.getY()>p.getY()+.5,"Native navigation produces an actual airborne displacement");
|
||||
h.assertTrue(h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Flying familiar stays out of solid blocks");
|
||||
equip(p,CompanionType.GHAST);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Ghast hovers rather than walking");
|
||||
equip(p,CompanionType.WITHER);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Wither hovers rather than walking");h.succeed();
|
||||
equip(p,CompanionType.GHAST);pet=FamiliarService.active(p);h.getLevel().tickNonPassenger(pet);h.assertTrue(pet.isNoGravity(),"Ghast hovers rather than walking");
|
||||
equip(p,CompanionType.WITHER);pet=FamiliarService.active(p);h.getLevel().tickNonPassenger(pet);h.assertTrue(pet.isNoGravity(),"Wither hovers rather than walking");h.succeed();
|
||||
}
|
||||
@GameTest public void aquaticPetFlopsAndThenSwims(GameTestHelper h){
|
||||
var p=player(h,CompanionType.SALMON);var pet=FamiliarService.active(p);pet.tick();
|
||||
h.assertTrue(!pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)<=.06,"Fish struggles on dry ground");
|
||||
h.assertTrue(!pet.isNoGravity()&&Math.abs(pet.getAttributeValue(Attributes.MOVEMENT_SPEED)-fr.koka.sanctuary.familiar.FamiliarBattle.profile(p).speed())<.0001,"Combat fish uses its species movement profile on dry ground");
|
||||
var at=p.blockPosition();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
|
||||
pet.setPos(p.position().add(2,0,0));p.baseTick();for(int i=0;i<15;i++)pet.tick();
|
||||
h.assertTrue(pet.isInWater()&&pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)>.3,"Fish switches to buoyant native swimming in water");h.succeed();
|
||||
@@ -150,7 +153,7 @@ public final class Companion019GameTests {
|
||||
}finally{CompanionTrades.cancel(p);villager.discard();}h.succeed();
|
||||
}
|
||||
@GameTest public void cropCyclesStopAfterOwnerLeaves(GameTestHelper h){
|
||||
var p=player(h,CompanionType.BEE);h.getLevel().addNewPlayer(p);var at=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at,Blocks.WHEAT.defaultBlockState());
|
||||
var p=player(h,CompanionType.BEE);equipForWork(p,CompanionType.BEE);h.getLevel().addNewPlayer(p);var at=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at,Blocks.WHEAT.defaultBlockState());
|
||||
try{
|
||||
var state=h.getLevel().getBlockState(at);var random=net.minecraft.util.RandomSource.create(42);
|
||||
CompanionProduction.cropRoll(h.getLevel(),at,state,random,25,1);String raw=h.getLevel().getAttached(CompanionProduction.CROPS);
|
||||
@@ -161,7 +164,7 @@ public final class Companion019GameTests {
|
||||
}finally{p.discard();}h.succeed();
|
||||
}
|
||||
@GameTest public void furnaceBonusConsumesFuelAndKeepsOneOutput(GameTestHelper h){
|
||||
var p=player(h,CompanionType.BLAZE);h.getLevel().addNewPlayer(p);
|
||||
var p=player(h,CompanionType.BLAZE);equipForWork(p,CompanionType.BLAZE);h.getLevel().addNewPlayer(p);
|
||||
try{
|
||||
var a=p.blockPosition().offset(2,0,0);var b=p.blockPosition().offset(6,0,0);
|
||||
h.getLevel().setBlockAndUpdate(a,Blocks.FURNACE.defaultBlockState());h.getLevel().setBlockAndUpdate(b,Blocks.FURNACE.defaultBlockState());
|
||||
@@ -195,6 +198,16 @@ public final class Companion019GameTests {
|
||||
var at=p.blockPosition();for(int x=-7;x<=7;x++)for(int z=-7;z<=7;z++)for(int y=-1;y<8;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
|
||||
p.setOnGround(true);equip(p,type);return p;
|
||||
}
|
||||
static void equipForWork(ServerPlayer p,CompanionType type){
|
||||
equip(p,type);
|
||||
if(CompanionService.passive(p)!=null)throw new AssertionError("Combat mode must suppress legacy work bonuses");
|
||||
if(!CompanionService.record(p).unlocked()){
|
||||
p.giveExperienceLevels(CompanionService.config(p).activeCost());
|
||||
if(!CompanionService.buy(p))throw new AssertionError("Fixture purchases work access through the server");
|
||||
}
|
||||
fr.koka.sanctuary.familiar.FamiliarBattle.command(p,"work");
|
||||
if(CompanionService.passive(p)!=type)throw new AssertionError("Explicit work command enables the attuned species");
|
||||
}
|
||||
static void equip(ServerPlayer p,CompanionType type){
|
||||
var item=BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(type.id()+"_spawn_egg"));
|
||||
AccessoryAccess.of(p).setItem(0,new ItemStack(item));p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);CompanionService.tick(p);
|
||||
|
||||
+2
-2
@@ -75,9 +75,9 @@ public final class Companion032GameTests {
|
||||
h.assertTrue(CompanionBudget.state(p).horizontal()==0&&!CompanionBudget.state(p).rabbit(),"Actual stable terrain rearms flight");h.succeed();
|
||||
}
|
||||
@GameTest public void axolotlFoodDurationAndGolemKnockbackUseNativeHooks(GameTestHelper h){
|
||||
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);
|
||||
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);Companion019GameTests.equipForWork(p,CompanionType.AXOLOTL);
|
||||
h.assertTrue(new ItemStack(Items.BREAD).getUseDuration(p)==29,"Food is ten percent faster on land");
|
||||
Companion019GameTests.equip(p,CompanionType.IRON_GOLEM);
|
||||
Companion019GameTests.equipForWork(p,CompanionType.IRON_GOLEM);
|
||||
h.assertTrue(Math.abs(CompanionPassives.knockback(p,1,null)-.8)<.00001,"Golem grants constant twenty percent knockback reduction");h.succeed();
|
||||
}
|
||||
@GameTest public void invalidAimedCushionDoesNotConsumeWool(GameTestHelper h){
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class Demeure006GameTests {
|
||||
return view==null ? 0 : view.influences().stream().filter(s -> s.subject().id().equals(player.getUUID().toString())).mapToDouble(FootprintStore.Share::score).sum();
|
||||
}
|
||||
private static boolean place(ServerPlayer player,Item item,BlockPos support) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(item));
|
||||
return ((BlockItem)item).place(new BlockPlaceContext(new UseOnContext(player,InteractionHand.MAIN_HAND,
|
||||
new BlockHitResult(Vec3.atCenterOf(support),Direction.UP,support,false)))).consumesAction();
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ public final class GravesFood038GameTests {
|
||||
}
|
||||
@GameTest public void ordinaryPlacementAndCreativeBreakPreserveGrave(GameTestHelper h){
|
||||
var p=player(h,1);var l=h.getLevel();var floor=h.absolutePos(new BlockPos(1,2,1));l.setBlockAndUpdate(floor,Blocks.STONE.defaultBlockState());var at=floor.above();
|
||||
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,8)));p.getInventory().setItem(0,head);p.setPos(at.getX()+3.5,at.getY(),at.getZ()+.5);
|
||||
var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND,8)));p.getInventory().setItem(0,head);p.setPos(at.getX()+2.5,at.getY(),at.getZ()+.5);
|
||||
var context=new net.minecraft.world.item.context.UseOnContext(p,net.minecraft.world.InteractionHand.MAIN_HAND,new net.minecraft.world.phys.BlockHitResult(net.minecraft.world.phys.Vec3.atCenterOf(floor).add(0,.5,0),Direction.UP,floor,false));
|
||||
head.useOn(context);h.assertTrue(l.getBlockEntity(at) instanceof SkullBlockEntity&&GraveService.isGrave(l.getBlockEntity(at)),"Native BlockItem placement carries grave components");
|
||||
var skull=(SkullBlockEntity)l.getBlockEntity(at);h.assertTrue(total(GraveService.head(skull).get(DataComponents.CONTAINER).nonEmptyItemCopyStream().toList(),Items.DIAMOND)==8,"Placed portable head keeps its items");
|
||||
@@ -130,7 +130,7 @@ public final class GravesFood038GameTests {
|
||||
@GameTest public void familiarSaturationPreviewUsesActualServerPassive(GameTestHelper h){
|
||||
var p=player(h,1);var progress=ProgressRecord.INITIAL;for(int i=0;i<7;i++)progress=progress.buy("hunger",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
|
||||
for(var species:new fr.koka.sanctuary.companion.CompanionType[]{fr.koka.sanctuary.companion.CompanionType.PIG,fr.koka.sanctuary.companion.CompanionType.MOOSHROOM}){
|
||||
Companion019GameTests.equip(p,species);h.assertTrue(fr.koka.sanctuary.companion.CompanionService.passive(p)==species,"Real familiar passive is ready");
|
||||
Companion019GameTests.equipForWork(p,species);h.assertTrue(fr.koka.sanctuary.companion.CompanionService.passive(p)==species,"Real familiar passive is ready");
|
||||
for(var item:new Item[]{Items.APPLE,Items.MUSHROOM_STEW,Items.COOKED_BEEF})for(int food:new int[]{4,13,19}){
|
||||
var stack=new ItemStack(item);p.getFoodData().setFoodLevel(food);p.getFoodData().setSaturation(2);var state=FoodSync.snapshot(p);var preview=state.preview(stack);
|
||||
h.assertTrue(state.stewMultiplier()>1&&(species==fr.koka.sanctuary.companion.CompanionType.PIG)==(state.foodMultiplier()>1),"Snapshot carries actual dietary modifiers");
|
||||
|
||||
+3
-3
@@ -58,8 +58,8 @@ public final class Inventory010GameTests {
|
||||
@GameTest public void everyNativeContainerKeepsItsIndices(GameTestHelper h) {
|
||||
var vanilla=Progression003GameTests.player(h);var p=player(h,5);int menus=0;
|
||||
for(var type:BuiltInRegistries.MENU) {
|
||||
AbstractContainerMenu before=type.create(12,vanilla.getInventory());
|
||||
AbstractContainerMenu menu=type.create(12,p.getInventory());
|
||||
AbstractContainerMenu before=InventoryMenuFixtures.create(type,12,vanilla.getInventory());
|
||||
AbstractContainerMenu menu=InventoryMenuFixtures.create(type,12,p.getInventory());
|
||||
var ext=(SanctuaryExtendedInventoryMenu)menu;
|
||||
ext.sanctuary$ensureExtendedInventory();
|
||||
if(ext.sanctuary$playerInventoryStart()<0) continue;
|
||||
@@ -77,7 +77,7 @@ public final class Inventory010GameTests {
|
||||
@GameTest public void machineQuickMovesAndMounts(GameTestHelper h) {
|
||||
var p=player(h,5);
|
||||
var menus=new ArrayList<AbstractContainerMenu>();
|
||||
for(var type:BuiltInRegistries.MENU) menus.add(type.create(16,p.getInventory()));
|
||||
for(var type:BuiltInRegistries.MENU) menus.add(InventoryMenuFixtures.create(type,16,p.getInventory()));
|
||||
var donkey=(net.minecraft.world.entity.animal.equine.AbstractHorse)BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("donkey")).create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.COMMAND);
|
||||
var nautilus=(net.minecraft.world.entity.animal.nautilus.AbstractNautilus)BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("nautilus")).create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.COMMAND);
|
||||
menus.add(new HorseInventoryMenu(17,p.getInventory(),new SimpleContainer(15),donkey,5));
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ public final class Inventory012GameTests {
|
||||
int compared=0;
|
||||
for(var type:BuiltInRegistries.MENU) for(Item item:new Item[]{Items.DIRT,Items.COAL,Items.IRON_ORE,Items.IRON_SWORD,Items.LAPIS_LAZULI,Items.DIAMOND,Items.PAPER,Items.GLASS_BOTTLE}) {
|
||||
var a=player(h,5);var b=player(h,5);
|
||||
var left=type.create(23,a.getInventory());var right=type.create(23,b.getInventory());
|
||||
var left=InventoryMenuFixtures.create(type,23,a.getInventory());var right=InventoryMenuFixtures.create(type,23,b.getInventory());
|
||||
var ext=extend(left);var other=extend(right);if(!ext.sanctuary$hasExtendedInventory()) continue;
|
||||
int nativeIndex=ext.sanctuary$playerInventoryStart(),extraIndex=other.sanctuary$overflowEnd()-1;
|
||||
a.getInventory().setItem(9,new ItemStack(item,Math.min(13,item.getDefaultMaxStackSize())));
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.koka.sanctuary.gametest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
import fr.koka.sanctuary.plans.ClayWorkshopMenu;
|
||||
import fr.koka.sanctuary.plans.Statuary;
|
||||
import fr.koka.sanctuary.multiblock.MultiblockMenu;
|
||||
import fr.koka.sanctuary.multiblock.Multiblocks;
|
||||
|
||||
/** Supply the opening data required by Sanctuary's extended menus; retain coverage of every registry entry. */
|
||||
final class InventoryMenuFixtures {
|
||||
private InventoryMenuFixtures() { }
|
||||
static AbstractContainerMenu create(MenuType<?> type,int id,Inventory inventory) {
|
||||
if(type==Statuary.MENU)return new ClayWorkshopMenu(id,inventory,new ClayWorkshopMenu.View(BlockPos.ZERO,new UUID(0,1),List.of()));
|
||||
if(type==Multiblocks.MENU)return new MultiblockMenu(id,inventory,new MultiblockMenu.View(false,0,1,"",0,java.util.stream.IntStream.range(0,54).toArray()));
|
||||
return type.create(id,inventory);
|
||||
}
|
||||
}
|
||||
+1
@@ -159,6 +159,7 @@ public final class MaterialActivityGameTests {
|
||||
}
|
||||
|
||||
private static boolean place(ServerPlayer player, Item item, BlockPos support) {
|
||||
player.setPos(support.getX()+2.5,support.getY()+1,support.getZ()+.5); // Within novice placement reach, outside the target cell.
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||
var hit = new BlockHitResult(Vec3.atCenterOf(support), Direction.UP, support, false);
|
||||
return ((BlockItem) item).place(new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit))).consumesAction();
|
||||
|
||||
+10
@@ -338,6 +338,16 @@ public final class PopulationDiagnostics {
|
||||
helper.assertTrue(cell.x()>=owner.minX() && cell.x()<=owner.maxX() && cell.z()>=owner.minZ() && cell.z()<=owner.maxZ(),
|
||||
"Hydrology must never be truncated at or written across region ownership: "+cell);
|
||||
helper.assertTrue(ids.contains(cell.featureId()),"Each retained cell needs its complete owning feature");
|
||||
if (plan.isOutletCell(cell)) {
|
||||
// Alpha30 bank cuts remove rock and deliberately need no foundation.
|
||||
helper.assertTrue(!cell.hasWater() && cell.sedimentDepth() == 0
|
||||
&& cell.carveTop() > cell.bedY(),
|
||||
"A bank opening must only carve, never add sediment or a source: " + cell);
|
||||
helper.assertTrue(plan.spills().stream().anyMatch(spill -> spill.id() == cell.featureId()
|
||||
&& spill.toFeatureId() == -1),
|
||||
"Every bank opening belongs to a declared terminal spill: " + cell);
|
||||
continue;
|
||||
}
|
||||
for(int y=cell.bedY()-cell.sedimentDepth()-1;y<=cell.bedY();y++)
|
||||
helper.assertTrue(random.sampleBlockValueUncached(
|
||||
generator.generatorSettings().value().noiseRouter().finalDensity(),cell.x(),y,cell.z())>0,
|
||||
|
||||
+4
-2
@@ -119,8 +119,10 @@ public final class Progression003GameTests {
|
||||
@GameTest
|
||||
public void serverCustomNameConfiguration(GameTestHelper helper) {
|
||||
var root=helper.getLevel().getServer().getCommands().getDispatcher().getRoot().getChild("sanctuary");
|
||||
helper.assertTrue(!root.canUse(player(helper).createCommandSourceStack())
|
||||
&& root.canUse(helper.getLevel().getServer().createCommandSourceStack()),"Names registration preserves existing operator-only Sanctuary commands");
|
||||
var ordinary=player(helper).createCommandSourceStack();var console=helper.getLevel().getServer().createCommandSourceStack();
|
||||
helper.assertTrue(root.canUse(ordinary)&&root.getChild("intro").canUse(ordinary),"Public Sanctuary navigation stays available");
|
||||
helper.assertTrue(!root.getChild("names").canUse(ordinary)&&root.getChild("names").canUse(console),"Names administration remains operator-only");
|
||||
helper.assertTrue(!root.getChild("community").getChild("admin").canUse(ordinary)&&root.getChild("community").getChild("admin").canUse(console),"Community administration remains operator-only");
|
||||
var config=MobNamesConfig.parse("{\"schema\":1,\"enabled\":true,\"types\":{\"minecraft:cow\":{\"includeDefaults\":false,\"names\":[\"Marguerite\",\"Rosalie\"]},\"example:creature\":{\"includeDefaults\":true,\"names\":[\"Pouet\"]}}}");
|
||||
helper.assertTrue(config.types().get("minecraft:cow").names().size()==2
|
||||
&& !config.types().get("minecraft:cow").includeDefaults(),"Per-type replacement and custom names");
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ public final class UnifiedWorldGameTests {
|
||||
PopulationDiagnostics.verifyDensity(helper);
|
||||
var level = helper.getLevel();
|
||||
var binding = binding(level);
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 384, "Unification preserves the complete vertical world extent");
|
||||
helper.assertTrue(level.getMinY() == 0 && level.getHeight() == 640, "Unification preserves the complete vertical world extent");
|
||||
var spawn = SanctuaryWorldGameTests.initialSpawn;
|
||||
helper.assertTrue(spawn != null, "Capture the original natural spawn before the GameTest fixtures move it");
|
||||
var at = spawn.pos();
|
||||
|
||||
@@ -43,6 +43,8 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
fr.koka.sanctuary.community.CommunityNetwork.register();
|
||||
fr.koka.sanctuary.community.ServerMetrics.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,40 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** Full-width hero photo, headline and description share one dark article container. */
|
||||
final class CommunityArticle extends AbstractWidget implements AutoCloseable {
|
||||
private final int imageWidth,imageHeight;
|
||||
private final float titleScale=1.35f;
|
||||
private final List<FormattedCharSequence> title,body;
|
||||
private CommunityPhotoWidget photo;
|
||||
CommunityArticle(int width,JsonObject entry) {
|
||||
super(0,0,width,100,Component.literal(str(entry,"title")));imageWidth=width-20;imageHeight=Math.max(40,imageWidth*9/16);
|
||||
var font=Minecraft.getInstance().font;
|
||||
title=font.split(getMessage().copy().withStyle(net.minecraft.ChatFormatting.BOLD),Math.max(30,(int)((width-24)/titleScale)));
|
||||
body=font.split(Component.literal(str(entry,"body")),Math.max(40,width-24));
|
||||
height=12+imageHeight+14+title.size()*15+10+body.size()*11+12;
|
||||
try {
|
||||
if(!str(entry,"photo").isEmpty())photo=new CommunityPhotoWidget(imageWidth,imageHeight,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(str(entry,"photo")),960,540),text("photo"),false,null);
|
||||
}catch(Exception ignored){}
|
||||
active=false;
|
||||
}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),tx=x+12,top=y+12+imageHeight+14;
|
||||
g.fill(x,y,getRight(),getBottom(),0xed171d23);
|
||||
if(photo!=null){photo.setPosition(x+10,y+12);photo.extractRenderState(g,mx,my,delta);}
|
||||
else g.text(font,text("photo_unavailable"),x+10,y+12,0xffaaaaaa,false);
|
||||
for(int i=0;i<title.size();i++){g.pose().pushMatrix();g.pose().translate(tx,top+i*15);g.pose().scale(titleScale,titleScale);g.text(font,title.get(i),0,0,0xfff4f0e8,false);g.pose().popMatrix();}
|
||||
top+=title.size()*15+12;for(int i=0;i<body.size();i++)g.text(font,body.get(i),tx,top+i*11,0xffd7dce0,false);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
|
||||
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
|
||||
}
|
||||
@@ -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,60 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.component.ResolvableProfile;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** A single accessible card, with a face/title row and a category-coloured frame. */
|
||||
final class CommunityCard extends AbstractButton implements AutoCloseable {
|
||||
private final JsonObject entry;
|
||||
private final Runnable action;
|
||||
private final PlayerFaceWidget face;
|
||||
private final boolean compact,article;
|
||||
private CommunityPhotoWidget photo;
|
||||
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xffead47e;};}
|
||||
static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff34301f;};}
|
||||
CommunityCard(int width,JsonObject entry,boolean compact,Runnable action) {
|
||||
super(0,0,width,compact?44:90,Component.literal(str(entry,"title")));this.entry=entry;this.compact=compact;this.action=action;article=str(entry,"kind").equals("article");
|
||||
face=new PlayerFaceWidget(compact?20:24,ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId"))));
|
||||
setTooltip(Tooltip.create(getMessage().copy().append("\n").append(str(entry,"authorName")).append("\n").append(CommunityScreen.date(entry))));
|
||||
if(article&&!compact) {
|
||||
height=width*9/16+78;
|
||||
try {
|
||||
String thumbnail=str(entry,"thumbnail");
|
||||
if(!thumbnail.isEmpty())photo=new CommunityPhotoWidget(width-12,width*9/16,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(thumbnail),160,90),text("photo"),false,null);
|
||||
}catch(Exception ignored){}
|
||||
}
|
||||
}
|
||||
PlayerFaceWidget face(){return face;}
|
||||
@Override public void onPress(InputWithModifiers input){action.run();}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
int x=getX(),y=getY(),accent=article?0xffaebfae:color(str(entry,"category"));var font=Minecraft.getInstance().font;
|
||||
g.fill(x,y,getRight(),getBottom(),article?0xff202720:background(str(entry,"category")));
|
||||
g.fill(x,y,x+3,getBottom(),accent);
|
||||
if(isHoveredOrFocused()){g.fill(x,y,getRight(),y+1,accent);g.fill(x,getBottom()-1,getRight(),getBottom(),accent);g.fill(getRight()-1,y,getRight(),getBottom(),accent);}
|
||||
int top=y+6;
|
||||
if(article&&!compact) {
|
||||
if(photo!=null){photo.setPosition(x+6,top);photo.extractRenderState(g,mx,my,delta);}else g.text(font,text("photo_unavailable"),x+10,top+12,0xffaaaaaa,false);
|
||||
top+=width*9/16+6;
|
||||
}
|
||||
face.setPosition(x+7,top);face.extractRenderState(g,mx,my,delta);
|
||||
int tx=x+(compact?33:37),tw=width-(compact?40:44);
|
||||
var lines=font.split(getMessage(),tw);
|
||||
for(int i=0;i<Math.min(compact?1:2,lines.size());i++)g.text(font,lines.get(i),tx,top+(compact?6:i*10),0xfff4f0e8,false);
|
||||
if(compact)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+31,accent,false);
|
||||
else {
|
||||
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,top+30,0xffd6d4c9,false);
|
||||
g.text(font,font.plainSubstrByWidth(CommunityScreen.date(entry),width-14),x+7,top+42,0xffa2aaa4,false);
|
||||
if(!article)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,top+59,accent,false);
|
||||
}
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
|
||||
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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, followTicks;
|
||||
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 = ""; fr.koka.sanctuary.community.CommunityTask task=fr.koka.sanctuary.community.CommunityTask.EMPTY; }
|
||||
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()==0 && r.json().equals("{\"admin\":true}")){c.client().gui.setScreen(CommunityScreen.admin(null));return;}
|
||||
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(++followTicks%100==0 && pending==0 && client.player!=null && (client.gui.screen()==null || client.gui.screen() instanceof SanctuaryAtlasScreen || client.gui.screen() instanceof PauseAtlasAccess) && available())request(null,"following","","","notice","info","","",0,0,r->{});
|
||||
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
|
||||
});
|
||||
}
|
||||
private static void finish(JsonObject result) {
|
||||
pending = 0;
|
||||
if(result.has("following")){NotificationClient.community(result.getAsJsonArray("following"));CommunityMapMarkers.accept(result.getAsJsonArray("following"));}
|
||||
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,84 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import fr.koka.sanctuary.community.CommunityTask;
|
||||
import java.util.*;
|
||||
import java.util.function.DoubleToIntFunction;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** Locations deliberately shared by followed notices; these never reveal terrain or generate chunks. */
|
||||
final class CommunityMapMarkers {
|
||||
record Marker(String id,String title,String author,String summary,CommunityTask task,int color) { }
|
||||
record Pin(Marker marker,int x,int y) {
|
||||
boolean contains(double mx,double my){return Math.abs(mx-x)<=7 && Math.abs(my-y)<=8;}
|
||||
}
|
||||
private static List<Marker> markers=List.of();
|
||||
private CommunityMapMarkers() { }
|
||||
static void clear(){markers=List.of();}
|
||||
static void accept(JsonArray entries) {
|
||||
var next=new ArrayList<Marker>();
|
||||
for(var value:entries) {
|
||||
var e=value.getAsJsonObject();
|
||||
if(!CommunityClient.str(e,"status").equals("open") || !e.has("task") || e.get("task").isJsonNull())continue;
|
||||
var task=new com.google.gson.Gson().fromJson(e.get("task"),CommunityTask.class);
|
||||
if(task.location()!=null)next.add(new Marker(CommunityClient.str(e,"id"),CommunityClient.str(e,"title"),CommunityClient.str(e,"authorName"),CommunityClient.str(e,"summary"),task,CommunityCard.color(CommunityClient.str(e,"category"))));
|
||||
}
|
||||
markers=List.copyOf(next);
|
||||
}
|
||||
static List<Pin> project(String dimension,DoubleToIntFunction screenX,DoubleToIntFunction screenZ,int left,int top,int right,int bottom) {
|
||||
var pins=new ArrayList<Pin>();
|
||||
for(var marker:markers) {
|
||||
var location=marker.task().location();if(!location.dimension().equals(dimension))continue;
|
||||
int x=screenX.applyAsInt(location.x()+.5),y=screenZ.applyAsInt(location.z()+.5);
|
||||
if(x>=left+7 && x<right-7 && y>=top+8 && y<bottom-8)pins.add(new Pin(marker,x,y));
|
||||
}
|
||||
return List.copyOf(pins);
|
||||
}
|
||||
static Pin at(List<Pin> pins,double x,double y){return pins.stream().filter(p->p.contains(x,y)).findFirst().orElse(null);}
|
||||
static boolean render(GuiGraphicsExtractor g,List<Pin> pins,int mouseX,int mouseY) {
|
||||
var font=Minecraft.getInstance().font;
|
||||
for(var pin:pins) {
|
||||
int x=pin.x(),y=pin.y();
|
||||
g.fill(x-7,y-8,x+8,y+9,pin.contains(mouseX,mouseY)?0xfff4e7b4:pin.marker().color());
|
||||
g.fill(x-6,y-7,x+7,y+8,0xff20251e);
|
||||
g.fill(x-1,y-5,x+2,y+2,0xffffd35a);
|
||||
g.fill(x-1,y+4,x+2,y+6,0xffffd35a);
|
||||
}
|
||||
var hovered=at(pins,mouseX,mouseY);
|
||||
if(hovered==null)return false;
|
||||
g.setTooltipForNextFrame(font,tooltip(hovered.marker()),mouseX,mouseY);
|
||||
return true;
|
||||
}
|
||||
static List<net.minecraft.util.FormattedCharSequence> tooltip(Marker marker) {
|
||||
var client=Minecraft.getInstance();var font=client.font;var task=marker.task();var location=task.location();
|
||||
int width=Math.min(230,client.getWindow().getGuiScaledWidth()-28);
|
||||
var lines=new ArrayList<net.minecraft.util.FormattedCharSequence>();
|
||||
lines.addAll(font.split(Component.literal(marker.title()).withStyle(net.minecraft.ChatFormatting.BOLD),width).stream().limit(2).toList());
|
||||
lines.add(Component.literal(font.plainSubstrByWidth(marker.author(),width)).getVisualOrderText());
|
||||
lines.addAll(font.split(Component.literal(marker.summary()),width).stream().limit(3).toList());
|
||||
lines.addAll(font.split(Component.literal("X "+location.x()+" Y "+location.y()+" Z "+location.z()),width));
|
||||
if(!task.materials().isEmpty()) {
|
||||
lines.add(CommunityClient.text("materials").getVisualOrderText());
|
||||
itemLines(lines,task.materials(),width,2);
|
||||
}
|
||||
lines.addAll(font.split(CommunityClient.text("reward."+task.reward()),width));
|
||||
if(task.reward().equals("items"))itemLines(lines,task.rewards(),width,2);
|
||||
if(task.reward().equals("custom"))lines.addAll(font.split(Component.literal(task.customReward()),width).stream().limit(2).toList());
|
||||
lines.addAll(font.split(CommunityClient.text("audience."+task.audience()),width));
|
||||
int limit=Math.max(5,(client.getWindow().getGuiScaledHeight()-34)/font.lineHeight-2);
|
||||
if(lines.size()>limit){lines=new ArrayList<>(lines.subList(0,limit-1));lines.add(Component.literal("…").getVisualOrderText());}
|
||||
lines.addAll(font.split(CommunityClient.text("map_followed"),width));
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
private static void itemLines(List<net.minecraft.util.FormattedCharSequence> lines,List<CommunityTask.Material> items,int width,int limit) {
|
||||
var font=Minecraft.getInstance().font;
|
||||
for(var item:items.stream().limit(limit).toList()) {
|
||||
var name=net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item()))
|
||||
.map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));
|
||||
lines.addAll(font.split(Component.literal(item.count()+" × ").append(name),width));
|
||||
}
|
||||
if(items.size()>limit)lines.addAll(font.split(CommunityClient.text("map_more_materials",items.size()-limit),width));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityTask.Material;
|
||||
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.screens.Screen;
|
||||
import net.minecraft.client.input.InputWithModifiers;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
/** A virtual chest: selections are descriptors and never touch a player inventory. */
|
||||
final class CommunityMaterialsScreen extends BlocodexFrame {
|
||||
private final List<Material> selected;
|
||||
private final Consumer<List<Material>> save;
|
||||
private final Map<String,Slot> selectedSlots=new HashMap<>();
|
||||
private String search="",chosen="";
|
||||
private boolean searchChanged,quantityValid=true;
|
||||
private Button saveButton;
|
||||
private LinearLayout results;
|
||||
private ScrollableLayout scroll;
|
||||
CommunityMaterialsScreen(Screen parent,List<Material> items,Consumer<List<Material>> save){super(parent);selected=new ArrayList<>(items);this.save=save;}
|
||||
@Override protected void init(){
|
||||
quantityValid=true;selectedSlots.clear();initFrame("history",text("materials"));
|
||||
int w=Math.min(400,contentWidth-24),x=(width-w)/2;
|
||||
var layout=LinearLayout.vertical().spacing(5);
|
||||
var query=layout.addChild(new EditBox(font,w,20,text("material_search")));query.setValue(search);
|
||||
query.setResponder(value->{search=value;searchChanged=true;});
|
||||
if(searchChanged){setInitialFocus(query);query.moveCursorToEnd(false);searchChanged=false;}
|
||||
results=LinearLayout.vertical().spacing(2);
|
||||
scroll=new ScrollableLayout(minecraft,results,Math.max(40,Math.min(100,(contentBottom-contentTop)/3)),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
layout.addChild(scroll);refreshResults(w);
|
||||
layout.addChild(new StringWidget(text("selected_materials",selected.size(),27),font));
|
||||
for(int row=0;row<3;row++){
|
||||
var slots=layout.addChild(LinearLayout.horizontal().spacing(2));
|
||||
for(int col=0;col<9;col++){
|
||||
int index=row*9+col;
|
||||
Material item=index<selected.size()?selected.get(index):null;
|
||||
var slot=slots.addChild(new Slot(item==null?"minecraft:air":item.item(),item==null?0:item.count(),()->{if(item!=null){chosen=item.item();rebuildWidgets();}}));if(item!=null)selectedSlots.put(item.item(),slot);
|
||||
}
|
||||
}
|
||||
if(!chosen.isEmpty()){
|
||||
var material=selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElse(null);
|
||||
if(material!=null){
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(4));
|
||||
row.addChild(new StringWidget(text("quantity"),font));
|
||||
var count=row.addChild(new EditBox(font,64,20,text("quantity")));count.setMaxLength(6);count.setValue(""+material.count());
|
||||
count.setTooltip(Tooltip.create(Component.literal("1–999999")));count.setResponder(value->{quantityValid=value.matches("[0-9]{1,6}")&&Integer.parseInt(value)>0;if(saveButton!=null)saveButton.active=quantityValid;if(quantityValid){int index=selected.indexOf(selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElseThrow());selected.set(index,new Material(chosen,Integer.parseInt(value)));var slot=selectedSlots.get(chosen);slot.count=Integer.parseInt(value);slot.setMessage(Component.literal(chosen+" × "+value));slot.setTooltip(Tooltip.create(slot.stack.isEmpty()?slot.getMessage():slot.stack.getHoverName().copy().append(" × "+value)));}});
|
||||
row.addChild(Button.builder(text("remove_material"),b->{selected.removeIf(i->i.item().equals(chosen));chosen="";rebuildWidgets();}).width(110).build());
|
||||
}
|
||||
}
|
||||
saveButton=layout.addChild(Button.builder(text("save"),b->{if(quantityValid){save.accept(List.copyOf(selected));onClose();}}).width(w).build());saveButton.active=quantityValid;
|
||||
var outer=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);outer.setPosition(x,contentTop);outer.arrangeElements();outer.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
@Override public void tick(){if(searchChanged)rebuildWidgets();}
|
||||
private void refreshResults(int w){
|
||||
if(results==null)return;
|
||||
String query=search.toLowerCase(Locale.ROOT);int count=0;LinearLayout row=null;
|
||||
for(var item:BuiltInRegistries.ITEM){
|
||||
String id=BuiltInRegistries.ITEM.getKey(item).toString();
|
||||
if(id.length()>100||id.equals("minecraft:air")||!(id.contains(query)||item.getDefaultInstance().getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)))continue;
|
||||
if(count%9==0)row=results.addChild(LinearLayout.horizontal().spacing(2));
|
||||
row.addChild(new Slot(id,1,()->{if(selected.stream().noneMatch(i->i.item().equals(id))&&selected.size()<27)selected.add(new Material(id,1));chosen=id;rebuildWidgets();}));
|
||||
if(++count>=180)break;
|
||||
}
|
||||
if(count==0)results.addChild(new StringWidget(text("empty"),font));
|
||||
scroll.arrangeElements();
|
||||
}
|
||||
static void showItems(LinearLayout layout,int width,List<Material> items){
|
||||
int columns=Math.max(1,Math.min(9,width/24));LinearLayout row=null;int index=0;
|
||||
for(var item:items){if(index++%columns==0)row=layout.addChild(LinearLayout.horizontal().spacing(2));row.addChild(new Slot(item.item(),item.count(),null));}
|
||||
}
|
||||
static final class Slot extends AbstractButton {
|
||||
private final ItemStack stack;private int count;private final Runnable action;
|
||||
Slot(String id,int count,Runnable action){super(0,0,22,22,Component.literal(id+" × "+count));this.count=count;this.action=action;stack=BuiltInRegistries.ITEM.getOptional(Identifier.parse(id)).map(i->i.getDefaultInstance()).orElse(ItemStack.EMPTY);setTooltip(Tooltip.create(stack.isEmpty()?getMessage():stack.getHoverName().copy().append(" × "+count)));}
|
||||
@Override public void onPress(InputWithModifiers input){if(action!=null)action.run();}
|
||||
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta){
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY();g.fill(x,y,x+22,y+22,isHoveredOrFocused()?0xff536957:0xff20242b);
|
||||
if(!stack.isEmpty()){g.item(stack,x+3,y+2);if(count>1){String label=count>999?"999+":""+count;g.text(font,label,x+22-font.width(label),y+14,0xffffffff,true);}}
|
||||
}
|
||||
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out){defaultButtonNarrationText(out);}
|
||||
}
|
||||
@Override public boolean isPauseScreen(){return false;}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.components.PlayerSkinWidget;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.world.entity.player.PlayerSkin;
|
||||
import net.minecraft.world.item.component.ResolvableProfile;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.*;
|
||||
|
||||
/** A GUI-only author portrait and persistent speech bubble; no world entity is created. */
|
||||
final class CommunityNoticeScene extends AbstractWidget {
|
||||
private final JsonObject entry;
|
||||
private final List<FormattedCharSequence> heading,title,body;
|
||||
private final Portrait portrait;
|
||||
private final int portraitWidth,bubbleX;
|
||||
CommunityNoticeScene(int width,JsonObject entry) {
|
||||
super(0,0,width,160,Component.literal(str(entry,"title")).append("\n").append(str(entry,"body")));
|
||||
this.entry=entry;
|
||||
portraitWidth=Math.clamp(width/4,56,94);bubbleX=portraitWidth+10;
|
||||
var font=Minecraft.getInstance().font;int wrap=Math.max(36,width-bubbleX-18);
|
||||
heading=font.split(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))),wrap);
|
||||
title=font.split(Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD),wrap);
|
||||
body=font.split(Component.literal(str(entry,"body")),wrap);
|
||||
height=Math.max(154,28+(heading.size()+title.size()+body.size())*11);
|
||||
portrait=new Portrait(portraitWidth,140,skin(entry));active=false;
|
||||
}
|
||||
private static Supplier<PlayerSkin> skin(JsonObject entry) {
|
||||
var m=Minecraft.getInstance();var uuid=UUID.fromString(str(entry,"authorId"));
|
||||
var fallback=m.playerSkinRenderCache().createLookup(ResolvableProfile.createUnresolved(uuid));
|
||||
return ()->{
|
||||
var connection=m.getConnection();var online=connection==null?null:connection.getPlayerInfo(uuid);
|
||||
return online!=null?online.getSkin():fallback.get().playerSkin();
|
||||
};
|
||||
}
|
||||
float yaw(){return portrait.yaw;}
|
||||
float pitch(){return portrait.pitch;}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
int x=getX(),y=getY(),bx=x+bubbleX,accent=CommunityCard.color(str(entry,"category"));
|
||||
g.fill(x,y,getRight(),getBottom(),0xA013171C);
|
||||
portrait.setPosition(x,y+6);portrait.extractRenderState(g,mx,my,delta);
|
||||
g.fill(bx,y,getRight(),getBottom(),CommunityCard.background(str(entry,"category")));
|
||||
g.outline(bx,y,width-bubbleX,height,accent);
|
||||
for(int step=0;step<6;step++)g.fill(bx-step-1,y+39+step,bx-step,y+50-step,accent);
|
||||
int top=y+9;
|
||||
for(var line:heading){g.text(Minecraft.getInstance().font,line,bx+9,top,accent,false);top+=11;}
|
||||
top+=5;
|
||||
for(var line:title){g.text(Minecraft.getInstance().font,line,bx+9,top,0xfffaf5e6,false);top+=11;}
|
||||
top+=5;
|
||||
for(var line:body){g.text(Minecraft.getInstance().font,line,bx+9,top,0xffeeeae1,false);top+=11;}
|
||||
}
|
||||
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
|
||||
|
||||
private static final class Portrait extends PlayerSkinWidget {
|
||||
private float pitch=-5,yaw=30;
|
||||
Portrait(int width,int height,Supplier<PlayerSkin> skin){super(width,height,Minecraft.getInstance().getEntityModels(),skin);active=false;}
|
||||
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
float nextYaw=(float)Math.toDegrees(Math.atan((mx-getX()-getWidth()/2f)/160f))*0.55f;
|
||||
float nextPitch=-(float)Math.toDegrees(Math.atan((my-getY()-30)/180f))*0.4f;
|
||||
// Native 26.3 skin widget applies 2.5 degrees per drag unit; use bounded absolute targets.
|
||||
super.onDrag(null,(nextYaw-yaw)/2.5,(pitch-nextPitch)/2.5);
|
||||
yaw=nextYaw;pitch=nextPitch;
|
||||
super.extractWidgetRenderState(g,mx,my,delta);
|
||||
}
|
||||
@Override protected void onDrag(MouseButtonEvent event,double dx,double dy) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 Calendar date;
|
||||
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;
|
||||
int margin=8,gap=10,top=50,bottom=screen.height-39;
|
||||
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;
|
||||
int optionHeight=screen.height<280?16:20,navGap=screen.height<280?3:4,groupGap=screen.height<280?8:12;
|
||||
int optionsTop=bottom-(3*optionHeight+8);
|
||||
int navHeight=Math.min(20,Math.max(11,(optionsTop-top-8-4*navGap-groupGap)/5));
|
||||
int step=navHeight+navGap;
|
||||
add.accept(new Frame(screen.width,screen.height,left,top+2*step+(groupGap-navGap)/2));
|
||||
date=new Calendar(margin,3,left);
|
||||
add.accept(date);
|
||||
bulletin=new PauseTextButton(centerX,3,screen.width-margin-centerX,38,text("bulletin"),()->open(screen,"bulletin",items("bulletins").isEmpty()?"":str(items("bulletins").get(0).getAsJsonObject(),"id"),false));
|
||||
add.accept(bulletin);
|
||||
String[] sections={"blocks","progress","identity"};
|
||||
for(int i=0;i<sections.length;i++) {
|
||||
String target=sections[i];int y=top+i*step+(i>=2?groupGap:0);
|
||||
add.accept(Button.builder(fr.koka.sanctuary.progression.ProgressionService.label(target),b->ProgressionClient.open(screen,target))
|
||||
.bounds(margin,y,left,navHeight).build());
|
||||
}
|
||||
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+3*step+groupGap,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+4*step+groupGap,left,navHeight).build());
|
||||
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
|
||||
int resumeWidth=Math.min(200,screen.width-24);
|
||||
place(nativeButtons,add,"menu.returnToGame",(screen.width-resumeWidth)/2,screen.height-26,resumeWidth);
|
||||
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,optionHeight);
|
||||
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+optionHeight+4,left,optionHeight);
|
||||
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
|
||||
button.setRectangle(left,optionHeight,margin,optionsTop+2*(optionHeight+4));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) {
|
||||
place(buttons,add,key,x,y,width,20);
|
||||
}
|
||||
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width,int height) {
|
||||
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,height,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.update(now,locale);
|
||||
var list=items("bulletins");
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title"));
|
||||
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")))));
|
||||
int left=date.getRight()+10,right=screen.width-8;
|
||||
bulletin.setRectangle(Math.max(24,right-left),38,left,3);
|
||||
bulletin.active=available();
|
||||
}
|
||||
/** Shares Progression's native header/footer separator textures. */
|
||||
private static final class Frame extends AbstractWidget {
|
||||
private final int left,groupY;
|
||||
Frame(int width,int height,int left,int groupY){super(0,0,width,height,Component.empty());this.left=left;this.groupY=groupY;active=false;}
|
||||
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var texture=Minecraft.getInstance().level==null?Screen.HEADER_SEPARATOR:Screen.INWORLD_HEADER_SEPARATOR;
|
||||
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,44,0,0,width,2,32,2);
|
||||
texture=Minecraft.getInstance().level==null?Screen.FOOTER_SEPARATOR:Screen.INWORLD_FOOTER_SEPARATOR;
|
||||
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,height-33,0,0,width,2,32,2);
|
||||
g.fill(12,groupY,left+4,groupY+1,0x665F6869);
|
||||
}
|
||||
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out) { }
|
||||
}
|
||||
private static final class Calendar extends StringWidget {
|
||||
private String day="",monthYear="",time="";
|
||||
private final int calendarWidth;
|
||||
Calendar(int x,int y,int width){super(x,y,width,38,Component.empty(),Minecraft.getInstance().font);calendarWidth=width;}
|
||||
void update(ZonedDateTime now,Locale locale){
|
||||
day=DateTimeFormatter.ofPattern("d",locale).format(now);monthYear=DateTimeFormatter.ofPattern("MMMM yyyy",locale).format(now);time=DateTimeFormatter.ofPattern("HH:mm",locale).format(now);
|
||||
setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy HH:mm",locale).format(now)));
|
||||
setWidth(calendarWidth);
|
||||
}
|
||||
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
|
||||
var font=Minecraft.getInstance().font;int x=getX(),y=getY();
|
||||
g.fill(x,y,getRight(),getBottom(),0xA0202529);g.outline(x,y,width,height,0xFF66716E);
|
||||
g.pose().pushMatrix();g.pose().translate(x+6,y+4);g.pose().scale(1.8f,1.8f);
|
||||
g.text(font,day,0,0,0xFFF0EEE6,false);g.pose().popMatrix();
|
||||
g.text(font,time,getRight()-6-font.width(time),y+9,0xFFE0DED6,false);
|
||||
// Keep the month and year together even at the smallest GUI width.
|
||||
float scale=Math.min(1f,(width-12f)/Math.max(1,font.width(monthYear)));
|
||||
g.pose().pushMatrix();g.pose().translate(x+6,y+25);g.pose().scale(scale,scale);
|
||||
g.text(font,monthYear,0,0,0xFFE0DED6,false);g.pose().popMatrix();
|
||||
}
|
||||
}
|
||||
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,333 @@
|
||||
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 final boolean admin;
|
||||
private String search="";
|
||||
private int searchAfter;
|
||||
private boolean rebuildSearch;
|
||||
private EditBox searchBox;
|
||||
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 final java.util.List<AutoCloseable> cards=new java.util.ArrayList<>();
|
||||
private final java.util.List<JsonObject> entries=new java.util.ArrayList<>();
|
||||
private boolean more,taskSent;
|
||||
private ScrollableLayout contentScroll;
|
||||
private double restoreScroll;
|
||||
private boolean publishing;
|
||||
private int photoPart, sendAfter;
|
||||
private String outgoingPhoto="";
|
||||
private Runnable commit;
|
||||
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.clear();cards.forEach(c->{try{c.close();}catch(Exception ignored){}});cards.clear();}
|
||||
@Override public void removed() {closePhotos();publishing=false;commit=null;}
|
||||
|
||||
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
|
||||
this(parent,kind,id,compose,false);
|
||||
}
|
||||
static CommunityScreen admin(Screen parent){return new CommunityScreen(parent,"bulletin","",false,true);}
|
||||
private CommunityScreen(Screen parent,String kind,String id,boolean compose,boolean admin) {
|
||||
super(parent); this.kind = kind; this.admin=admin; this.id = id; this.composing = compose && (!kind.equals("bulletin") || admin);
|
||||
moderator = flag(overview,"moderator");
|
||||
if (compose) useDraft("new:" + kind);
|
||||
}
|
||||
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
|
||||
@Override protected void rebuildWidgets() {
|
||||
boolean restoreFocus=searchBox!=null && searchBox.isFocused() && !composing && id.isEmpty();
|
||||
int cursor=restoreFocus?searchBox.getCursorPosition():0;
|
||||
super.rebuildWidgets();
|
||||
if(restoreFocus && searchBox!=null){setInitialFocus(searchBox);searchBox.moveCursorTo(cursor,false);}
|
||||
}
|
||||
@Override protected void init() {
|
||||
searchBox=null;
|
||||
closePhotos();initFrame("history",text(kind)); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
|
||||
if(!composing && (kind.equals("notice") || kind.equals("article")) && data.has("thread")) {communityDetail(data.getAsJsonObject("thread"),w);return;}
|
||||
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);
|
||||
contentScroll=scroll;if(restoreScroll>0){scroll.visitChildren(child->{if(child instanceof net.minecraft.client.gui.components.AbstractScrollArea area)area.setScrollAmount(restoreScroll);});restoreScroll=0;}
|
||||
}
|
||||
private void listing(LinearLayout layout,int w) {
|
||||
var tabs=layout.addChild(LinearLayout.horizontal().spacing(6));
|
||||
if(!admin)for(String target:new String[]{"article","notice","bulletin"})tabs.addChild(Button.builder(text(target.equals("bulletin")?"server_tab":target.equals("notice")?"notice_tab":target),b->open(parent,target,"",false)).width((w-12)/3).build());
|
||||
searchBox=layout.addChild(new EditBox(font,w,20,text("search")));
|
||||
searchBox.setMaxLength(120);searchBox.setHint(text("search"));searchBox.setValue(search);
|
||||
searchBox.setResponder(value->{search=value;page=0;more=false;entries.clear();restoreScroll=0;needsLoad=true;searchAfter=ticks+6;rebuildSearch=true;});
|
||||
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(6));
|
||||
if(!kind.equals("bulletin") || admin)button(toolbar,(w-6)/2,"publish",()->{composing=true;useDraft("new:"+kind);rebuildWidgets();});
|
||||
button(toolbar,(w-6)/2,"refresh",()->{page=0;needsLoad=true;});
|
||||
if(entries.isEmpty())line(layout,w,text(needsLoad?"loading":"empty"));
|
||||
int cardWidth=(w-8)/2;
|
||||
for(int i=0;i<entries.size();i+=2) {
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
|
||||
for(int j=i;j<Math.min(i+2,entries.size());j++) {
|
||||
var entry=entries.get(j);
|
||||
var card=new CommunityCard(cardWidth,entry,false,()->{id=str(entry,"id");page=0;data=new JsonObject();needsLoad=true;});
|
||||
cards.add(card);row.addChild(card);
|
||||
}
|
||||
}
|
||||
if(more)button(layout,w,"more",()->{restoreScroll=contentScroll.getScrollAmount();page++;needsLoad=true;});
|
||||
}
|
||||
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);
|
||||
if(kind.equals("article")) {
|
||||
var article=new CommunityArticle(w,entry);cards.add(article);layout.addChild(article);
|
||||
articleAuthor(layout,w,entry);
|
||||
if(expandedId.equals(id))controls(layout,w,entry);
|
||||
} else {
|
||||
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));message(layout,w,entry);
|
||||
if(kind.equals("notice"))taskDetails(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")) { replies(layout,w,thread);replyComposer(layout,w,entry); }
|
||||
}
|
||||
private void replies(LinearLayout layout,int w,JsonObject thread) {
|
||||
line(layout,w,text("replies"));
|
||||
var replies=thread.getAsJsonObject("replies");
|
||||
for(var item:replies.getAsJsonArray("entries"))message(layout,w,item.getAsJsonObject());
|
||||
if(replies.getAsJsonArray("entries").isEmpty())line(layout,w,text("conversation_empty"));
|
||||
pages(layout,w,replies);
|
||||
}
|
||||
private void replyComposer(LinearLayout layout,int w,JsonObject entry) {
|
||||
if(!str(entry,"status").equals("open"))return;
|
||||
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,Math.min(w,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 void communityDetail(JsonObject thread,int w) {
|
||||
var entry=thread.getAsJsonObject("entry");
|
||||
var toolbar=LinearLayout.horizontal().spacing(5);
|
||||
button(toolbar,90,"back",()->{id="";page=0;data=new JsonObject();needsLoad=true;rebuildWidgets();});
|
||||
button(toolbar,90,"refresh",()->needsLoad=true);
|
||||
toolbar.setPosition(contentX+8,contentTop);toolbar.arrangeElements();toolbar.visitWidgets(this::addRenderableWidget);
|
||||
int top=contentTop+28,availableHeight=contentBottom-top;
|
||||
boolean columns=w>=470 && availableHeight>=180;
|
||||
int leftWidth=columns?(w-20)*3/5:w;
|
||||
var scene=LinearLayout.vertical().spacing(8);
|
||||
if(!message.isEmpty())line(scene,leftWidth,text(message));
|
||||
if(!available())line(scene,leftWidth,text("unavailable"));
|
||||
if(kind.equals("article")) {
|
||||
var article=new CommunityArticle(leftWidth,entry);cards.add(article);scene.addChild(article);
|
||||
articleAuthor(scene,leftWidth,entry);
|
||||
} else {
|
||||
scene.addChild(new CommunityNoticeScene(leftWidth,entry));
|
||||
author(scene,leftWidth,entry,true);
|
||||
}
|
||||
if(expandedId.equals(id))controls(scene,leftWidth,entry);
|
||||
if(kind.equals("notice"))taskDetails(scene,leftWidth,entry);
|
||||
if(columns) {
|
||||
scrollColumn(scene,leftWidth,contentX+8,top,availableHeight);
|
||||
int rightWidth=w-leftWidth-20,rightX=contentX+8+leftWidth+20;
|
||||
var conversation=LinearLayout.vertical().spacing(8);replies(conversation,rightWidth,thread);
|
||||
int composeHeight=str(entry,"status").equals("open")?88:0;
|
||||
scrollColumn(conversation,rightWidth,rightX,top,availableHeight-composeHeight);
|
||||
if(composeHeight>0){var compose=LinearLayout.vertical().spacing(8);replyComposer(compose,rightWidth,entry);compose.setPosition(rightX,contentBottom-80);compose.arrangeElements();compose.visitWidgets(this::addRenderableWidget);}
|
||||
} else {
|
||||
replies(scene,w,thread);replyComposer(scene,w,entry);
|
||||
scrollColumn(scene,w,contentX+8,top,availableHeight);
|
||||
}
|
||||
}
|
||||
private void scrollColumn(LinearLayout layout,int w,int x,int y,int h) {
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,h),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setMinWidth(w);scroll.setPosition(x,y);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
|
||||
private void articleAuthor(LinearLayout layout,int w,JsonObject entry) {
|
||||
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")))));
|
||||
String when=date(entry),name=font.plainSubstrByWidth(str(entry,"authorName"),Math.max(20,w-76-font.width(when)));
|
||||
var metadata=Component.literal(name+" · "+when);
|
||||
row.addChild(new StringWidget(Math.min(w-58,font.width(metadata)),24,metadata,font));
|
||||
if(own(entry)||moderator)row.addChild(Button.builder(text("actions"),b->{expandedId=expandedId.equals(id)?"":id;rebuildWidgets();}).size(20,24).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
|
||||
}
|
||||
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,!kind.equals("bulletin") || admin);
|
||||
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) {
|
||||
if(kind.equals("bulletin") && !admin)return;
|
||||
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"); draft.task=readTask(entry); }
|
||||
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")) 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"));
|
||||
}
|
||||
if(!reply && kind.equals("notice")) {
|
||||
button(layout,w,"task_configure",()->minecraft.gui.setScreen(new CommunityTaskScreen(this,draft.task,value->draft.task=value)));
|
||||
line(layout,w,text("task_summary",draft.task.materials().size(),text("audience."+draft.task.audience())));
|
||||
}
|
||||
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")||kind.equals("notice"))) {outgoingPhoto=kind.equals("article")?draft.photo:"";photoPart=0;taskSent=!kind.equals("notice");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(Math.min(w,160),Math.max(24,Math.min(72,160*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 static fr.koka.sanctuary.community.CommunityTask readTask(JsonObject entry) {
|
||||
return entry.has("task")&&!entry.get("task").isJsonNull()?new Gson().fromJson(entry.get("task"),fr.koka.sanctuary.community.CommunityTask.class):fr.koka.sanctuary.community.CommunityTask.EMPTY;
|
||||
}
|
||||
private void taskDetails(LinearLayout layout,int w,JsonObject entry) {
|
||||
var task=readTask(entry);
|
||||
if(!task.equals(fr.koka.sanctuary.community.CommunityTask.EMPTY)) {
|
||||
line(layout,w,text("request_details"));
|
||||
if(task.location()!=null) {
|
||||
line(layout,w,text("location_value",task.location().dimension(),task.location().x(),task.location().y(),task.location().z()));
|
||||
var map=layout.addChild(Button.builder(text("map_show"),b->minecraft.gui.setScreen(new SanctuaryAtlasScreen(this,task.location()))).width(Math.min(w,220)).build());
|
||||
map.active=minecraft.level!=null && task.location().dimension().equals(minecraft.level.dimension().identifier().toString());
|
||||
if(!map.active)map.setTooltip(Tooltip.create(text("map_other_dimension")));
|
||||
}
|
||||
if(!task.materials().isEmpty()) {line(layout,w,text("materials"));CommunityMaterialsScreen.showItems(layout,w,task.materials());}
|
||||
line(layout,w,text("audience."+task.audience()));line(layout,w,text("reward."+task.reward()));
|
||||
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
|
||||
if(task.reward().equals("custom"))line(layout,w,Component.literal(task.customReward()));
|
||||
line(layout,w,text("exchange_help"));
|
||||
}
|
||||
int count=data.has("members")?data.getAsJsonArray("members").size():0;
|
||||
line(layout,w,text("followers",count));
|
||||
if(data.has("members"))for(var member:data.getAsJsonArray("members"))line(layout,w,Component.literal("• "+str(member.getAsJsonObject(),"playerName")));
|
||||
boolean subscribed=flag(data,"subscribed");
|
||||
if(str(entry,"status").equals("open"))line(layout,w,text(subscribed?"follow_map_active":"follow_map_help"));
|
||||
if(!subscribed && str(entry,"status").equals("open") && task.audience().equals("single") && count>0)line(layout,w,text("notice_full_help"));
|
||||
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
|
||||
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
|
||||
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
|
||||
rebuildWidgets();
|
||||
})).width(w).build());
|
||||
follow.active=subscribed || (!own(entry)&&str(entry,"status").equals("open")&&(!task.audience().equals("single")||count==0));
|
||||
}
|
||||
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 (!publishing) action.run(); }).width(w).build());
|
||||
}
|
||||
static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
|
||||
@Override public void tick() {
|
||||
super.tick(); ticks++;
|
||||
if(rebuildSearch){rebuildSearch=false;rebuildWidgets();}
|
||||
if(publishing && !pending() && ticks>=sendAfter) {
|
||||
if(!taskSent) {
|
||||
request(this,"task",editingId.isEmpty()?draft.operation:editingId,"","notice","info","",new GsonBuilder().disableHtmlEscaping().create().toJson(draft.task),0,0,result->{
|
||||
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
|
||||
else {taskSent=true;sendAfter=ticks+4;}
|
||||
});return;
|
||||
}
|
||||
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();}
|
||||
}
|
||||
String requestedSearch=search,requestedId=id;int requestedPage=page;
|
||||
if (!composing && needsLoad && ticks>=searchAfter && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","",id.isEmpty()?search:"",0,page,result -> {
|
||||
if(!search.equals(requestedSearch) || !id.equals(requestedId) || page!=requestedPage || composing)return;
|
||||
if (result.has("error")) message=str(result,"error");
|
||||
else {data=result;moderator=flag(result,"moderator");
|
||||
if(id.isEmpty()&&result.has("page")) {
|
||||
if(page==0)entries.clear();
|
||||
for(var entry:result.getAsJsonObject("page").getAsJsonArray("entries"))if(entries.stream().noneMatch(e->str(e,"id").equals(str(entry.getAsJsonObject(),"id"))))entries.add(entry.getAsJsonObject());
|
||||
more=flag(result.getAsJsonObject("page"),"more");
|
||||
}
|
||||
}
|
||||
rebuildWidgets();
|
||||
})) needsLoad=false;
|
||||
}
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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 access or source-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"));
|
||||
contentWidth=Math.max(240,width-24);contentX=(width-contentWidth)/2;
|
||||
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,contentTop+Math.max(24,contentBottom-contentTop-28)+8);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=Math.max(2,w/112),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);
|
||||
int left=(width-w)/2;
|
||||
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(left,contentTop);addRenderableWidget(widget);
|
||||
} catch(Exception e) {error="photo_invalid";selected=null;return;}
|
||||
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(left,contentTop+h+8,(w-6)/2,20).build());
|
||||
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(left+(w+6)/2,contentTop+h+8,(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,38 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityTask;
|
||||
import java.util.*;
|
||||
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 static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
final class CommunityTaskScreen extends BlocodexFrame {
|
||||
private final Consumer<CommunityTask> save;
|
||||
private CommunityTask.Location location;
|
||||
private List<CommunityTask.Material> materials,rewards;
|
||||
private String audience,reward,custom,error="";
|
||||
CommunityTaskScreen(Screen parent,CommunityTask task,Consumer<CommunityTask> save){super(parent);this.save=save;location=task.location();materials=task.materials();rewards=task.rewards();audience=task.audience();reward=task.reward();custom=task.customReward();}
|
||||
@Override protected void init(){
|
||||
initFrame("history",text("task_configure"));int w=Math.min(400,contentWidth-24);
|
||||
var layout=LinearLayout.vertical().spacing(8);
|
||||
if(!error.isEmpty())layout.addChild(new MultiLineTextWidget(text(error),font).setMaxWidth(w));
|
||||
layout.addChild(new MultiLineTextWidget(text("exchange_help"),font).setMaxWidth(w));
|
||||
button(layout,w,"location_current",()->{if(minecraft.player!=null){var p=minecraft.player.blockPosition();location=new CommunityTask.Location(minecraft.player.level().dimension().identifier().toString(),p.getX(),p.getY(),p.getZ());rebuildWidgets();}});
|
||||
if(location!=null){
|
||||
layout.addChild(new MultiLineTextWidget(text("location_value",location.dimension(),location.x(),location.y(),location.z()),font).setMaxWidth(w));
|
||||
button(layout,w,"location_remove",()->{location=null;rebuildWidgets();});
|
||||
}
|
||||
button(layout,w,"materials",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,materials,value->materials=value)));
|
||||
CommunityMaterialsScreen.showItems(layout,w,materials);
|
||||
button(layout,w,"audience."+audience,()->{audience=audience.equals("shared")?"single":"shared";rebuildWidgets();});
|
||||
button(layout,w,"reward."+reward,()->{reward=switch(reward){case "none"->"items";case "items"->"custom";default->"none";};rebuildWidgets();});
|
||||
if(reward.equals("items")){button(layout,w,"rewards_choose",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,rewards,value->rewards=value)));CommunityMaterialsScreen.showItems(layout,w,rewards);}
|
||||
if(reward.equals("custom")){var box=layout.addChild(new EditBox(font,w,20,text("reward.custom")));box.setMaxLength(240);box.setValue(custom);box.setResponder(value->custom=value);}
|
||||
button(layout,w,"save",()->{try{save.accept(new CommunityTask(location,materials,audience,reward,reward.equals("items")?rewards:List.of(),reward.equals("custom")?custom:""));onClose();}catch(fr.koka.sanctuary.community.Community.Refused e){error="task_invalid";rebuildWidgets();}});
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition((width-w)/2,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void button(LinearLayout layout,int w,String key,Runnable action){layout.addChild(Button.builder(text(key),b->action.run()).width(w).build());}
|
||||
@Override public boolean isPauseScreen(){return false;}
|
||||
}
|
||||
@@ -12,13 +12,25 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.FontDescription;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.util.FormattedCharSequence;
|
||||
import net.minecraft.ChatFormatting;
|
||||
|
||||
/** Configuration-stage identity and starter slots. Closing cancels connection, never the saved offer. */
|
||||
public final class HelloWorldScreen extends Screen {
|
||||
private static final FontDescription GALACTIC = new FontDescription.Resource(Identifier.withDefaultNamespace("alt"));
|
||||
private static final Style GALACTIC_STYLE = Style.EMPTY.withFont(GALACTIC);
|
||||
private ProgressionPayloads.Hello offer;private final Runnable disconnect;
|
||||
private EditBox bio,familiarName;private int color=-1;private Button submit;private boolean waiting,identityLocked;
|
||||
private String draft="",starter="",nameDraft="";private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private String draft="",starter="",nameDraft="",codeDraft="",codeStatus="",lastChecked="";
|
||||
private java.net.URI accessSite;
|
||||
public void setAccessSite(String url) {
|
||||
try { var uri=java.net.URI.create(url); accessSite=("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) && uri.getHost()!=null && uri.getUserInfo()==null ? uri : null; }
|
||||
catch(IllegalArgumentException error) { accessSite=null; }
|
||||
}
|
||||
private boolean accessRequired,accessPending,admitting;private EditBox codeBox;
|
||||
private List<StarterCompanions.Choice> starterChoices=List.of();
|
||||
private final List<Button> colors=new ArrayList<>(),starters=new ArrayList<>();
|
||||
private int panelX,panelY,panelWidth,bioLabelY,colorLabelY,starterLabelY,nameLabelY;private boolean compact;
|
||||
public HelloWorldScreen(ProgressionPayloads.Hello offer,Runnable disconnect){super(ProgressionService.label("hello_title"));this.offer=offer;this.disconnect=disconnect;}
|
||||
@@ -29,30 +41,48 @@ public final class HelloWorldScreen extends Screen {
|
||||
if(identityLocked){draft=welcome.bio();color=offer.colors().getFirst();}
|
||||
if(starterChoices.stream().noneMatch(c->c.mob().equals(starter)))starter="";
|
||||
}
|
||||
public void accept(StarterPayloads.Welcome welcome){if(bio!=null)draft=bio.getValue();load(welcome);waiting=false;if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){this.offer=offer;waiting=false;if(bio!=null)draft=bio.getValue();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void prepareAccess(boolean pending){accessRequired=true;accessPending=pending;if(pending)codeStatus="code_recognized";}
|
||||
public void requireAccess(boolean pending){boolean changed=!accessRequired||accessPending!=pending;prepareAccess(pending);if(changed&&minecraft!=null&&minecraft.gui.screen()==this){keepDrafts();rebuildWidgets();}}
|
||||
public void acceptCode(String code,String reason){if(admitting)return;if(!AccessCodes.normalize(code).equals(AccessCodes.normalize(codeDraft))){if(AccessCodes.validFormat(AccessCodes.normalize(codeDraft)))sendCheck(AccessCodes.normalize(codeDraft));return;}codeStatus=reason;waiting=false;updateSelection();}
|
||||
public void accept(StarterPayloads.Welcome welcome){keepDrafts();load(welcome);waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
public void accept(ProgressionPayloads.Hello offer){keepDrafts();this.offer=offer;waiting=false;admitting=false;noteCodeError();if(!offer.colors().contains(color))color=-1;rebuildWidgets();}
|
||||
private void keepDrafts(){if(bio!=null)draft=bio.getValue();if(codeBox!=null)codeDraft=codeBox.getValue();if(familiarName!=null)nameDraft=familiarName.getValue();}
|
||||
private void noteCodeError(){if(offer.error().startsWith("code_"))codeStatus=offer.error();}
|
||||
@Override protected void init(){
|
||||
boolean naming=!starterChoices.isEmpty();compact=naming&&height<278;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-(naming&&!compact?270:238))/2);colors.clear();starters.clear();familiarName=null;
|
||||
bioLabelY=panelY+(compact?37:44);colorLabelY=panelY+(compact?71:81);starterLabelY=panelY+(compact?145:177);nameLabelY=starterLabelY+36;
|
||||
boolean naming=!starterChoices.isEmpty();int shift=accessRequired?36:0;compact=height<278+shift;
|
||||
panelWidth=Math.min(520,width-20);panelX=(width-panelWidth)/2;panelY=Math.max(0,(height-((compact?202:naming?270:238)+shift))/2);colors.clear();starters.clear();familiarName=null;codeBox=null;
|
||||
bioLabelY=panelY+(compact?37:44)+shift;colorLabelY=panelY+(compact?71:81)+shift;starterLabelY=panelY+(compact?145:177)+shift;nameLabelY=starterLabelY+(compact?0:36);
|
||||
var skin=addRenderableWidget(new PlayerSkinWidget(64,145,minecraft.getEntityModels(),minecraft.getSkinManager().createLookup(minecraft.getGameProfile(),false)));skin.setPosition(panelX,panelY+35);
|
||||
int right=panelX+72,w=panelWidth-72;
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
if(accessRequired){
|
||||
codeBox=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57),accessSite==null?w:w-116,18,ProgressionService.label("code")));
|
||||
if(accessSite!=null)addRenderableWidget(Button.builder(ProgressionService.label("get_code"),b->{keepDrafts();net.minecraft.client.gui.screens.ConfirmLinkScreen.confirmLinkNow(this,accessSite);}).bounds(right+w-112,panelY+(compact?48:57),112,18).build());
|
||||
codeBox.setMaxLength(32);codeBox.setHint(Component.translatable("sanctuary.progression.code_hint").withStyle(style -> style.withFont(GALACTIC)));codeBox.setValue(codeDraft);
|
||||
codeBox.addFormatter((text,offset) -> FormattedCharSequence.forward(text, GALACTIC_STYLE));
|
||||
codeBox.setResponder(value->{codeDraft=value;if(accessPending){updateSelection();return;}String normalized=AccessCodes.normalize(value);if(!AccessCodes.validFormat(normalized)){codeStatus=normalized.isEmpty()?"":"code_malformed";lastChecked="";updateSelection();return;}if(!normalized.equals(lastChecked))sendCheck(normalized);else updateSelection();});
|
||||
}
|
||||
bio=addRenderableWidget(new EditBox(font,right,panelY+(compact?48:57)+shift,w,18,ProgressionService.label("bio")));bio.setMaxLength(256);bio.setHint(ProgressionService.label("bio_hint"));bio.setValue(draft);bio.setResponder(value->draft=value);
|
||||
int cell=w/4;
|
||||
for(int i=0;i<offer.colors().size();i++){
|
||||
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
final int rgb=offer.colors().get(i);int rowHeight=compact?12:16;Button button=addRenderableWidget(new ColorButton(right+(i%4)*cell,panelY+(compact?82:94)+shift+(i/4)*rowHeight,cell-2,rowHeight,rgb,i+1,b->{color=rgb;updateSelection();}));
|
||||
button.setTooltip(Tooltip.create(InhabitantColorNames.label(rgb)));colors.add(button);
|
||||
}
|
||||
for(int i=0;i<starterChoices.size();i++)starters.add(addRenderableWidget(new StarterSlot(right+i*28,starterLabelY+11,starterChoices.get(i))));
|
||||
if(naming){
|
||||
familiarName=addRenderableWidget(new EditBox(font,right,nameLabelY+12,w,18,Component.translatable("sanctuary.starter.name")));
|
||||
familiarName=addRenderableWidget(new EditBox(font,right+(compact?88:0),nameLabelY+12,w-(compact?88:0),18,Component.translatable("sanctuary.starter.name")));
|
||||
familiarName.setMaxLength(StarterCompanions.NAME_LIMIT);familiarName.setHint(Component.translatable("sanctuary.starter.name_hint"));familiarName.setValue(nameDraft);
|
||||
familiarName.setResponder(value->{nameDraft=value;updateSelection();});
|
||||
}
|
||||
int actionsY=panelY+(naming&&!compact?248:216);
|
||||
int actionsY=panelY+(compact?181:naming?248:216)+shift;
|
||||
addRenderableWidget(Button.builder(ProgressionService.label("cancel"),b->onClose()).bounds(panelX,actionsY,90,20).build());
|
||||
submit=addRenderableWidget(Button.builder(ProgressionService.label("enter"),b->{
|
||||
if(waiting||color<0||(!starterChoices.isEmpty()&&(starter.isEmpty()||!StarterCompanions.validName(nameDraft))))return;
|
||||
if(accessRequired){
|
||||
if(!(accessPending||"code_recognized".equals(codeStatus))){if(retryCode())sendCheck(AccessCodes.normalize(codeDraft));return;}
|
||||
admitting=true;waiting=true;updateSelection();
|
||||
ClientConfigurationNetworking.send(new AccessPayloads.Admit(bio.getValue(),color,starterChoices.isEmpty()?"":starter,nameDraft,accessPending||codeBox==null?"":codeBox.getValue()));
|
||||
return;
|
||||
}
|
||||
if(!starterChoices.isEmpty()&&!nameDraft.isBlank()&&!ClientConfigurationNetworking.canSend(StarterPayloads.NamedCreate.TYPE)){
|
||||
offer=new ProgressionPayloads.Hello(offer.name(),offer.colors(),"starter_name_version_required");return;
|
||||
}
|
||||
@@ -62,9 +92,14 @@ public final class HelloWorldScreen extends Screen {
|
||||
else ClientConfigurationNetworking.send(new StarterPayloads.Create(bio.getValue(),color,starter));
|
||||
}).bounds(panelX+96,actionsY,panelWidth-96,20).build());updateSelection();
|
||||
}
|
||||
private void sendCheck(String normalized){lastChecked=normalized;codeStatus="code_checking";updateSelection();if(ClientConfigurationNetworking.canSend(AccessPayloads.Check.TYPE))ClientConfigurationNetworking.send(new AccessPayloads.Check(normalized));}
|
||||
private boolean retryCode(){return accessRequired&&!accessPending&&AccessCodes.validFormat(AccessCodes.normalize(codeDraft))&&(codeStatus.equals("code_unreachable")||codeStatus.equals("code_unauthorized"));}
|
||||
private void updateSelection(){
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)));
|
||||
boolean codeOk=!accessRequired||accessPending||"code_recognized".equals(codeStatus)||retryCode();
|
||||
if(submit!=null)submit.setMessage(ProgressionService.label(waiting?"joining":retryCode()?"retry_code":"enter"));
|
||||
if(submit!=null)submit.active=!waiting&&color>=0&&(starterChoices.isEmpty()||(!starter.isEmpty()&&StarterCompanions.validName(nameDraft)))&&codeOk;
|
||||
if(familiarName!=null)familiarName.setEditable(!waiting);
|
||||
if(codeBox!=null)codeBox.setEditable(!waiting&&!accessPending);
|
||||
if(bio!=null)bio.setEditable(!waiting&&!identityLocked);colors.forEach(b->b.active=!waiting&&!identityLocked);starters.forEach(b->b.active=!waiting);
|
||||
}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
@@ -72,14 +107,21 @@ public final class HelloWorldScreen extends Screen {
|
||||
}
|
||||
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float a){
|
||||
super.extractRenderState(g,mx,my,a);g.centeredText(font,title,width/2,panelY+7,0xFFFFFFFF);
|
||||
g.text(font,Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),0xFFFFFFFF);
|
||||
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
|
||||
String status=waiting?"joining":offer.error();
|
||||
if(!starterChoices.isEmpty()){
|
||||
g.text(font,status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
|
||||
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72,nameLabelY,0xFFBBBBBB);
|
||||
boolean compactError=compact&&!offer.error().isEmpty()&&!offer.error().startsWith("code_");
|
||||
g.text(font,compactError?ProgressionService.label(offer.error()):Component.literal(offer.name()).withStyle(s->color<0?s:s.withColor(color)),panelX+72,panelY+(compact?24:29),compactError?0xFFFFFFAA:0xFFFFFFFF);
|
||||
if(accessRequired){
|
||||
String codeKey=codeStatus.isEmpty()?"code":codeStatus;
|
||||
int codeColor="code_recognized".equals(codeStatus)?0xFF88CC88:codeStatus.isEmpty()?0xFFBBBBBB:0xFFFFFFAA;
|
||||
g.text(font,ProgressionService.label(codeKey),panelX+72,panelY+(compact?37:44),codeColor);
|
||||
}
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+204,0xFFFFFFAA);
|
||||
g.text(font,ProgressionService.label("bio"),panelX+72,bioLabelY,0xFFBBBBBB);g.text(font,ProgressionService.label("choose_color"),panelX+72,colorLabelY,0xFFBBBBBB);
|
||||
String shown=offer.error().startsWith("code_")?"":offer.error();
|
||||
String status=waiting?"joining":shown;
|
||||
if(!starterChoices.isEmpty()){
|
||||
g.text(font,compact?Component.translatable("sanctuary.starter.compact_slots"):status.isEmpty()?Component.translatable("sanctuary.starter.slots"):ProgressionService.label(status),panelX+72,starterLabelY,compact||status.isEmpty()?0xFFBBBBBB:0xFFFFFFAA);
|
||||
g.text(font,Component.translatable("sanctuary.starter.name"),panelX+72+(compact?88:0),nameLabelY,0xFFBBBBBB);
|
||||
}
|
||||
else if(!status.isEmpty())g.centeredText(font,ProgressionService.label(status),width/2,panelY+(compact?169:204)+(accessRequired?36:0),0xFFFFFFAA);
|
||||
}
|
||||
private final class ColorButton extends Button.Plain {
|
||||
ColorButton(int x,int y,int w,int h,int rgb,int number,OnPress press){super(x,y,w,h,InhabitantColorNames.label(rgb).copy().withStyle(s->s.withColor(rgb)),press,ignored->ProgressionService.label("color_number",number).copy().append(": ").append(InhabitantColorNames.label(rgb)));setOverrideRenderHighlightedSprite(()->color==rgb||isHoveredOrFocused());}
|
||||
|
||||
@@ -21,7 +21,20 @@ public final class HotbarClient {
|
||||
private static boolean held;
|
||||
private static boolean worldHeld;
|
||||
private HotbarClient() {}
|
||||
static void migratePlayerListKey(net.minecraft.client.Minecraft client) {
|
||||
var marker=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
if(java.nio.file.Files.exists(marker))return;
|
||||
try {
|
||||
if(client.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB))) {
|
||||
client.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U));
|
||||
KeyMapping.resetMapping();client.options.save();
|
||||
}
|
||||
java.nio.file.Files.createDirectories(marker.getParent());
|
||||
java.nio.file.Files.writeString(marker,"beta.161\n",java.nio.file.StandardOpenOption.CREATE_NEW);
|
||||
} catch(java.io.IOException error) {fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot record player list key migration",error);}
|
||||
}
|
||||
public static void register(KeyMapping.Category category) {
|
||||
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.CLIENT_STARTED.register(HotbarClient::migratePlayerListKey);
|
||||
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("screen.sanctuary.inventory.rotate_key",InputConstants.Type.KEYBOARD,InputConstants.KEY_TAB,category));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(HotbarPayloads.Capability.TYPE,(p,c) -> {});
|
||||
ClientPlayNetworking.registerGlobalReceiver(HotbarPayloads.State.TYPE,(p,c) -> c.client().execute(() -> {
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class NotificationClient {
|
||||
private static double animatedBlocks,animatedItems,animatedRecipes;
|
||||
private static boolean hasRoom=true;
|
||||
private static String trackingScope;
|
||||
private static List<Track> communityTracks=List.of();
|
||||
private NotificationClient(){}
|
||||
public static NotificationOptions options(){if(config==null)config=NotificationOptions.load();return config;}
|
||||
public static Component label(String key,Object...args){return Component.translatable("sanctuary.notifications."+key,args);}
|
||||
@@ -40,10 +41,24 @@ public final class NotificationClient {
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
|
||||
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
|
||||
}
|
||||
public static void reset(){FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
|
||||
public static void reset(){CommunityMapMarkers.clear();communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
|
||||
public static void clearFeed(){FEED.clear();}
|
||||
public static List<NotificationFeed.Entry<Message>> messages(){return FEED.entries();}
|
||||
public static List<Track> tracked(){return OBJECTIVES.shown(options().pinnedAdvancements,options().advancements);}
|
||||
public static List<Track> tracked(){var tracks=new ArrayList<>(OBJECTIVES.shown(options().pinnedAdvancements,options().advancements));if(options().pinnedAdvancements)tracks.addAll(communityTracks);return tracks.stream().distinct().limit(6).toList();}
|
||||
public static void communitySubscribed(Component title){push("community:follow",2,CommunityClient.text("followed",title),0x80c1a4);}
|
||||
public static void community(com.google.gson.JsonArray entries){
|
||||
var tracks=new ArrayList<Track>();var snapshots=new ArrayList<ObjectiveTracker.Objective<Track>>();
|
||||
for(var value:entries){
|
||||
var e=value.getAsJsonObject();var task=new com.google.gson.Gson().fromJson(e.get("task"),fr.koka.sanctuary.community.CommunityTask.class);
|
||||
boolean closed=CommunityClient.str(e,"status").equals("closed");
|
||||
Component detail=CommunityClient.text(closed?"closed":"open");
|
||||
if(task.location()!=null)detail=detail.copy().append(" · "+task.location().x()+", "+task.location().y()+", "+task.location().z());
|
||||
if(!task.materials().isEmpty()) {var item=task.materials().getFirst();var name=BuiltInRegistries.ITEM.getOptional(Identifier.parse(item.item())).map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));detail=detail.copy().append(" · "+item.count()+" × ").append(name);}
|
||||
var track=new Track(Component.literal(CommunityClient.str(e,"title")),detail,closed?1:0,CommunityCard.color(CommunityClient.str(e,"category"))&0xffffff);
|
||||
snapshots.add(new ObjectiveTracker.Objective<>(CommunityClient.str(e,"id"),track,closed?1:0,closed));if(!closed&&tracks.size()<3)tracks.add(track);
|
||||
}
|
||||
objectives("community",snapshots);communityTracks=List.copyOf(tracks);
|
||||
}
|
||||
public static boolean isPinned(Identifier id){return OBJECTIVES.isPinned(new ObjectiveTracker.Key("advancement",id.toString()));}
|
||||
/** Presentation entry point for validated server snapshots, on the Minecraft client thread.
|
||||
* A quest adapter can use source "quest" and its own IDs without a second HUD. */
|
||||
@@ -111,7 +126,7 @@ public final class NotificationClient {
|
||||
long now=System.nanoTime()/1_000_000L,elapsed=lastClock==0?0:now-lastClock;lastClock=now;
|
||||
if(m.player==null||m.level==null)return;
|
||||
if(m.player!=inhabitant||!m.player.isAlive()){
|
||||
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();
|
||||
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();communityTracks=List.of();
|
||||
}
|
||||
if(!m.player.isAlive())return;
|
||||
ensureTracking(m);
|
||||
|
||||
@@ -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,114 @@
|
||||
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;}
|
||||
private double zoom(){return Math.max(.75,Math.max(width,height)/992.);}
|
||||
java.util.List<CommunityMapMarkers.Pin> questPins() {
|
||||
return CommunityMapMarkers.project(dimension,wx->getX()+width/2+(int)Math.round((wx-centerX)*zoom()),
|
||||
wz->getY()+height/2+(int)Math.round((wz-centerZ)*zoom()),getX(),getY()+20,getRight(),getBottom()-20);
|
||||
}
|
||||
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick) {
|
||||
var pin=CommunityMapMarkers.at(questPins(),event.x(),event.y());
|
||||
if(event.button()==com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT && pin!=null){CommunityClient.open(owner,"notice",pin.marker().id(),false);return true;}
|
||||
return super.mouseClicked(event,doubleClick);
|
||||
}
|
||||
@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=zoom();
|
||||
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);
|
||||
}
|
||||
boolean questHovered=CommunityMapMarkers.render(g,questPins(),mx,my);
|
||||
setTooltip(questHovered?null:Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
|
||||
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,76 @@
|
||||
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 CommunityCard(width,entry,true,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;}
|
||||
}
|
||||
@@ -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()+5,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);}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import net.minecraft.client.gui.screens.Screen;
|
||||
|
||||
public final class ProgressionClient {
|
||||
public static ProgressRecord progress;
|
||||
private static boolean accessGate,accessPending;
|
||||
private static String accessSite="";
|
||||
public static boolean operator;
|
||||
public static InhabitantStore.Identity identity;
|
||||
public static ProgressionConfig config=ProgressionConfig.DEFAULT;
|
||||
@@ -38,14 +40,25 @@ public final class ProgressionClient {
|
||||
CycleClient.register();
|
||||
EntityRendererRegistry.register(SanctuarySitting.SEAT,NoopRenderer::new);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.InventoryLayout.TYPE,(payload,context) -> {});
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Site.TYPE,(payload,context)->context.client().execute(()->{
|
||||
accessSite=payload.url();
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.setAccessSite(accessSite);
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Gate.TYPE,(payload,context)->context.client().execute(()->{
|
||||
accessGate=payload.required();accessPending=payload.pending();
|
||||
if(payload.required()&&context.client().gui.screen() instanceof HelloWorldScreen screen)screen.requireAccess(payload.pending());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AccessPayloads.Status.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.acceptCode(payload.code(),payload.reason());
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(StarterPayloads.Welcome.TYPE,(payload,context)->context.client().execute(()->{
|
||||
if(context.client().gui.screen() instanceof HelloWorldScreen screen)screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,()->context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(ProgressionPayloads.Hello.TYPE,(payload,context) ->
|
||||
context.client().execute(() -> {
|
||||
if (context.client().gui.screen() instanceof HelloWorldScreen screen) screen.accept(payload);
|
||||
else context.client().gui.setScreen(new HelloWorldScreen(payload,
|
||||
else showHello(context.client(),new HelloWorldScreen(payload,
|
||||
() -> context.responseSender().disconnect(ProgressionService.label("cancelled"))));
|
||||
}));
|
||||
ClientPlayNetworking.registerGlobalReceiver(ProgressionPayloads.Snapshot.TYPE,(payload,context) ->
|
||||
@@ -91,9 +104,11 @@ public final class ProgressionClient {
|
||||
if (active() && ClientPlayNetworking.canSend(ProgressionPayloads.Action.TYPE))
|
||||
ClientPlayNetworking.send(new ProgressionPayloads.Action(target,progress.revision()));
|
||||
}
|
||||
private static void showHello(Minecraft client,HelloWorldScreen screen){screen.setAccessSite(accessSite);if(accessGate)screen.prepareAccess(accessPending);client.gui.setScreen(screen);}
|
||||
private static void reset(Minecraft client) {
|
||||
if (client.player != null) ProgressEffects.clear(client.player);
|
||||
identity=null;progress=null;operator=false;config=ProgressionConfig.DEFAULT;names=MobNamesConfig.DEFAULT;
|
||||
accessGate=false;accessPending=false;accessSite="";
|
||||
wasSneaking=false;pendingSit=false;lastSneak=-100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
|
||||
private String status="loading";
|
||||
private String dimension="";
|
||||
|
||||
public SanctuaryAtlasScreen(Screen parent) {super(parent);}
|
||||
private final fr.koka.sanctuary.community.CommunityTask.Location initialTarget;
|
||||
public SanctuaryAtlasScreen(Screen parent) {this(parent,null);}
|
||||
SanctuaryAtlasScreen(Screen parent,fr.koka.sanctuary.community.CommunityTask.Location target) {super(parent);initialTarget=target;}
|
||||
public static Component label(String key,Object... args) {return Component.translatable("sanctuary.atlas."+key,args);}
|
||||
@Override protected void init() {
|
||||
dragging=false;
|
||||
@@ -75,7 +77,10 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
|
||||
minecraft.getTextureManager().register(TEXTURE,texture);
|
||||
footprintTexture=new DynamicTexture("Sanctuary atlas footprints",SIZE,SIZE,false);
|
||||
minecraft.getTextureManager().register(FOOTPRINT_TEXTURE,footprintTexture);
|
||||
clear();recenter();
|
||||
clear();
|
||||
if(initialTarget!=null && minecraft.level!=null && initialTarget.dimension().equals(minecraft.level.dimension().identifier().toString())) {
|
||||
centerX=initialTarget.x()+.5;centerZ=initialTarget.z()+.5;request();
|
||||
} else recenter();
|
||||
}
|
||||
}
|
||||
private void clear() {
|
||||
@@ -193,6 +198,8 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
|
||||
@Override public boolean mouseClicked(MouseButtonEvent event,boolean doubleClick) {
|
||||
// Disabled buttons also own their hit area; clicking one must never start a map gesture.
|
||||
if (overControl(event.x(),event.y())) return super.mouseClicked(event,doubleClick);
|
||||
var quest=CommunityMapMarkers.at(questPins(),event.x(),event.y());
|
||||
if(event.button()==InputConstants.MOUSE_BUTTON_LEFT && quest!=null){CommunityClient.open(this,"notice",quest.marker().id(),false);return true;}
|
||||
if (event.button()==InputConstants.MOUSE_BUTTON_LEFT && inside(event.x(),event.y()) && readable()) {
|
||||
setFocused(null);setDragging(false);dragging=true;return true;
|
||||
}
|
||||
@@ -267,13 +274,18 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
|
||||
graphics.textWithWordWrap(font,message,x,y,textWidth,0xFFE0E6EE);
|
||||
}
|
||||
renderCoordinates(graphics,mouseX,mouseY);
|
||||
if (!renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
|
||||
boolean questHint=CommunityMapMarkers.render(graphics,questPins(),mouseX,mouseY);
|
||||
if (!questHint && !renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
|
||||
}
|
||||
public void accept(fr.koka.sanctuary.atlas.AtlasMarkerPayload payload) {
|
||||
if (payload.token()!=token || !readable() || minecraft.level==null
|
||||
|| !dimension.equals(minecraft.level.dimension().identifier().toString())) return;
|
||||
markers=payload.markers();
|
||||
}
|
||||
java.util.List<CommunityMapMarkers.Pin> questPins() {
|
||||
return CommunityMapMarkers.project(dimension,this::screenX,this::screenZ,0,contentTop,width,height-62).stream()
|
||||
.filter(pin->!overControl(pin.x(),pin.y())).toList();
|
||||
}
|
||||
private Component markerName(fr.koka.sanctuary.atlas.AtlasMarker marker) {
|
||||
return marker.name().isBlank() ? label("marker_"+marker.kind()) : Component.literal(marker.name());
|
||||
}
|
||||
|
||||
@@ -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(Math.max(128,VanillaLight.pbrDistance()),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,lunar?0:1,0)
|
||||
.putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,0).putVec4(0,0,0,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(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,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.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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public final class VanillaLight {
|
||||
private static Preferences preferences;
|
||||
private VanillaLight() { }
|
||||
private static final class Preferences {
|
||||
boolean enabled=true;
|
||||
// Opt-in on fresh installations; explicit saved choices remain authoritative.
|
||||
boolean enabled=false;
|
||||
Boolean naturalWater; // Legacy OFF is an explicit opt-out in both world types.
|
||||
Boolean sanctuaryNaturalWater;
|
||||
Boolean ordinaryNaturalWater;
|
||||
@@ -31,8 +32,10 @@ public final class VanillaLight {
|
||||
boolean pbr=true;
|
||||
int pbrIntensity=50;
|
||||
int pbrBalanceVersion;
|
||||
int pbrDistance=32;
|
||||
boolean ssr=false;
|
||||
int pbrDistance=64;
|
||||
int ssrDistance=64;
|
||||
int reflectionDistanceVersion;
|
||||
boolean ssr=true;
|
||||
int ssrIntensity=20;
|
||||
boolean ssgi=true;
|
||||
boolean coloredLights=true;
|
||||
@@ -64,7 +67,10 @@ 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;
|
||||
@@ -114,6 +120,9 @@ 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:value<192?128:256;}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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 = 3, 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, CommunityTask task) {
|
||||
public Entry {
|
||||
task = kind.equals("notice")?Objects.requireNonNullElse(task,CommunityTask.EMPTY):task;
|
||||
if(!kind.equals("notice") && task!=null)fail("task_invalid");
|
||||
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, CommunityTask task) {
|
||||
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision,String photo) {
|
||||
this(action,id,parentId,kind,category,title,body,revision,photo,null);
|
||||
}
|
||||
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,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 search(String value) {
|
||||
if(value==null || value.codePointCount(0,value.length())>120)fail("invalid");
|
||||
value=value.strip();
|
||||
if(!value.isEmpty())text(value,120);
|
||||
return value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
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(),""))
|
||||
|| !Objects.equals(existing.task(),change.kind().equals("notice")?Objects.requireNonNullElse(change.task(),CommunityTask.EMPTY):change.task())) 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()),change.task());
|
||||
tx.save(entry, true); return entry;
|
||||
}
|
||||
Entry old = visible(tx.get(change.id()));
|
||||
if(old.kind().equals("bulletin")&&!actor.moderator())fail("forbidden");
|
||||
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
|
||||
if (old.revision() != change.revision()) fail("conflict");
|
||||
boolean own = old.authorId().equals(actor.id());
|
||||
CommunityTask task=old.task();String category=old.category();
|
||||
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());
|
||||
if(old.kind().equals("notice")) {
|
||||
task=change.task()==null?old.task():change.task();category=change.category();
|
||||
if(task.audience().equals("single") && tx.followers(old.id())>1)fail("notice_full");
|
||||
}
|
||||
}
|
||||
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(), category, old.authorId(), old.authorName(),
|
||||
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo, task);
|
||||
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,215 @@
|
||||
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() {
|
||||
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
|
||||
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("link").then(net.minecraft.commands.Commands.argument("code",com.mojang.brigadier.arguments.StringArgumentType.word()).executes(context->{
|
||||
request(context.getSource().getPlayerOrException(),new Request(0,"link","","","article","info","",com.mojang.brigadier.arguments.StringArgumentType.getString(context,"code"),0,0));return 1;
|
||||
}))))));
|
||||
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
|
||||
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("admin").requires(OperatorService::canCommand).executes(context->{
|
||||
var player=context.getSource().getPlayerOrException();
|
||||
if(ServerPlayNetworking.canSend(player,Response.TYPE))ServerPlayNetworking.send(player,new Response(0,"{\"admin\":true}"));
|
||||
return 1;
|
||||
})))));
|
||||
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()); runtime.tasks.remove(handler.player.getUUID()); }
|
||||
});
|
||||
}
|
||||
private static void send(ServerPlayer player, Request request, JsonObject data) {
|
||||
if(request.sequence()==0&&request.action().equals("link")){player.sendSystemMessage(net.minecraft.network.chat.Component.translatable("sanctuary.community."+(data.has("error")?data.get("error").getAsString():"linked")));return;}
|
||||
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","cards","following","task").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());
|
||||
if(request.kind().equals("bulletin") && !Set.of("overview","following","list","cards","thread").contains(request.action()) && !actor.moderator())fail("forbidden");
|
||||
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(),request.body())));
|
||||
case "cards" -> {
|
||||
if(!Set.of("article","notice").contains(request.kind()))fail("invalid");
|
||||
var page=store.list(request.kind(),"",request.page(),request.body());var cards=JSON.toJsonTree(page).getAsJsonObject();
|
||||
for(var value:cards.getAsJsonArray("entries")) {
|
||||
var entry=value.getAsJsonObject();String photo=entry.has("photo")?entry.get("photo").getAsString():"";
|
||||
if(!photo.isEmpty())entry.addProperty("thumbnail",runtime.thumbnail(entry.get("id").getAsString()+":"+entry.get("revision").getAsInt(),photo));
|
||||
entry.remove("body");entry.remove("task");
|
||||
}
|
||||
omitPhotos(cards);result.add("page",cards);
|
||||
}
|
||||
case "thread" -> {
|
||||
var thread=store.thread(request.id(),request.page());
|
||||
result.add("thread",JSON.toJsonTree(thread));
|
||||
if(thread.entry().kind().equals("notice")) {
|
||||
var members=store.members(request.id());result.add("members",JSON.toJsonTree(members));
|
||||
result.addProperty("subscribed",members.stream().anyMatch(f->f.playerId().equals(actor.id())));
|
||||
}
|
||||
}
|
||||
case "follow","unfollow" -> {
|
||||
store.follow(actor,request.id(),request.action().equals("follow"));
|
||||
result.add("following",following(store,actor));result.addProperty("saved",true);
|
||||
}
|
||||
case "following" -> result.add("following",following(store,actor));
|
||||
case "task" -> {
|
||||
runtime.tasks.values().removeIf(t->System.currentTimeMillis()-t.at()>120000);
|
||||
if(runtime.tasks.size()>=128 && !runtime.tasks.containsKey(player.getUUID()))fail("busy");
|
||||
if(request.body().length()>16000)fail("task_invalid");
|
||||
var task=JSON.fromJson(request.body(),CommunityTask.class);validateTask(server,task);
|
||||
runtime.tasks.put(player.getUUID(),new TaskDraft(uuid(request.id()),task,System.currentTimeMillis()));
|
||||
result.addProperty("saved",true);
|
||||
}
|
||||
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),runtime.task(player.getUUID(),request)))));
|
||||
}
|
||||
if(request.action().equals("list") || request.action().equals("overview")) {omitPhotos(result);omitTasks(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")); }
|
||||
}
|
||||
private record TaskDraft(String id,CommunityTask task,long at) { }
|
||||
private static void validateTask(MinecraftServer server,CommunityTask task) {
|
||||
if(task==null)fail("task_invalid");
|
||||
for(var item:java.util.stream.Stream.concat(task.materials().stream(),task.rewards().stream()).toList())
|
||||
if(net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item())).isEmpty())fail("task_invalid");
|
||||
if(task.location()!=null && server.levelKeys().stream().noneMatch(k->k.identifier().toString().equals(task.location().dimension())))fail("task_invalid");
|
||||
}
|
||||
private static JsonElement following(CommunityStore store,Actor actor) throws Exception {
|
||||
var result=JSON.toJsonTree(store.following(actor));omitPhotos(result);
|
||||
result.getAsJsonArray().forEach(e->{
|
||||
var entry=e.getAsJsonObject();String body=entry.remove("body").getAsString();
|
||||
int count=body.codePointCount(0,body.length());
|
||||
entry.addProperty("summary",count>240?body.substring(0,body.offsetByCodePoints(0,240))+"…":body);
|
||||
});return result;
|
||||
}
|
||||
private static void omitTasks(JsonElement value) {
|
||||
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitTasks);
|
||||
else if(value.isJsonObject()) {var object=value.getAsJsonObject();object.remove("task");object.entrySet().forEach(e->omitTasks(e.getValue()));}
|
||||
}
|
||||
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());
|
||||
}
|
||||
final Map<UUID,TaskDraft> tasks=new ConcurrentHashMap<>();
|
||||
final Map<String,String> thumbnails=new LinkedHashMap<>();
|
||||
String thumbnail(String key,String photo) throws Exception {
|
||||
if(thumbnails.containsKey(key))return thumbnails.get(key);
|
||||
String result=CommunityPhoto.thumbnail(photo);if(thumbnails.size()>=128)thumbnails.remove(thumbnails.keySet().iterator().next());
|
||||
thumbnails.put(key,result);return result;
|
||||
}
|
||||
CommunityTask task(UUID player,Request request) {
|
||||
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("notice"))return null;
|
||||
var draft=tasks.get(player);if(draft==null || !draft.id().equals(request.id()))return null;
|
||||
if(System.currentTimeMillis()-draft.at()>120000)fail("task_expired");return draft.task();
|
||||
}
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user