Compare commits

..
6 Commits
Author SHA1 Message Date
koka dbb9e3428d Lay out Gazette article and conversation for beta.165
Build Sanctuary / build (push) Waiting to run
2026-09-24 16:34:38 +02:00
koka 9a429c40a1 Align pause calendar and server bulletin for beta.164
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:56:27 +02:00
koka 905b3bcfa1 Reshape pause navigation and frame for beta.163
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:36:56 +02:00
koka 0c55a37562 Deliver beta.162 notice author scene and side conversation
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:38:09 +02:00
koka 8deba7cfb4 Deliver beta.161 playtest fixes and shared server metrics
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:20:06 +02:00
koka c1d2278674 beta.160: opt-in shaders and lightweight local duo test lab
Build Sanctuary / build (push) Waiting to run
2026-09-23 22:27:49 +02:00
38 changed files with 1399 additions and 74 deletions
+38
View File
@@ -1,5 +1,43 @@
# Sanctuary
## 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).
## 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 (pack local)
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
+60
View File
@@ -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`.
+155
View File
@@ -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.
+26
View File
@@ -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.
+66
View File
@@ -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.
+25
View File
@@ -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`.
+27
View File
@@ -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.
+78
View File
@@ -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.
+2 -2
View File
@@ -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.159
pack_version=beta.159
mod_version=beta.165
pack_version=beta.165
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+34
View File
@@ -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,6 +16,7 @@ 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))
@@ -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é."
}
+14
View File
@@ -128,6 +128,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') }
@@ -1241,3 +1247,11 @@ tasks.register('community154Interop', JavaExec) {
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') }
@@ -95,6 +95,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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"));
@@ -141,6 +142,8 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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);
@@ -182,6 +185,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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);});
@@ -194,19 +198,85 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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().endsWith(java.time.Year.now().toString())).findFirst().orElseThrow();
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("search159-pause-year-"+locale+"-"+scale);
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();}
}
@@ -54,14 +54,14 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{
var s=m.gui.screen();
check(s instanceof PauseAtlasAccess,"Pause atlas access");
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.history","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=40&&b.getY()<s.height-76).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
check(navigation.size()==6,"Six real navigation buttons");
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
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-8,"Options and quit stacked at bottom left");
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(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
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);
@@ -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);}
}
@@ -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); }
}
@@ -44,6 +44,7 @@ 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();
@@ -8,30 +8,27 @@ 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 net.minecraft.world.item.component.ResolvableProfile;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Photo left, generous headline and smaller text right, followed by the separate discussion. */
/** Full-width hero photo, headline and description share one dark article container. */
final class CommunityArticle extends AbstractWidget implements AutoCloseable {
private final JsonObject entry;
private final int imageWidth,textX;
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")));this.entry=entry;imageWidth=Math.max(72,width*42/100-16);textX=imageWidth+24;
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-textX-12)/titleScale)));
body=font.split(Component.literal(str(entry,"body")),Math.max(40,width-textX-12));
height=Math.max(imageWidth*9/16+24,24+title.size()*15+12+body.size()*11);
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,Math.max(40,imageWidth*9/16),fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(str(entry,"photo")),960,540),text("photo"),false,null);
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;
}
int textOffset(){return textX;}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),tx=x+textX,top=y+12;
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);
@@ -18,8 +18,8 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
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->0xff80b2d2;};}
private static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff202d37;};}
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"))));
@@ -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) { }
}
}
@@ -16,67 +16,101 @@ import static fr.koka.sanctuary.client.CommunityClient.*;
public final class CommunityPausePanels implements AutoCloseable {
private final Screen screen;
private final PauseCommunityFeed articles, notices;
private final StringWidget date, clock;
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;
var font=Minecraft.getInstance().font;
int margin=8,gap=10,top=40,bottom=screen.height-38;
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;
date=new StringWidget(margin,8,left,20,Component.empty(),font);
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
add.accept(date);add.accept(clock);
int headerSide=Math.max(84,screen.width/5);
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->open(screen,"bulletin","",false));
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","history","progress","identity"};
int optionsTop=screen.height-76;
int navHeight=Math.min(20,Math.max(16,(optionsTop-top-28)/6));
int step=navHeight+4;
String[] sections={"blocks","progress","identity"};
for(int i=0;i<sections.length;i++) {
String target=sections[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,top+i*step,left,navHeight).build());
.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+4*step,left,navHeight).build());
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
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);
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
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);
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+24,left);
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,20,margin,optionsTop+48);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
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) {
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,20,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
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.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy",locale).format(now)));
clock.setMessage(Component.literal(DateTimeFormatter.ofPattern("HH:mm",locale).format(now)));
clock.setX(screen.width-8-Minecraft.getInstance().font.width(clock.getMessage()));clock.setWidth(Minecraft.getInstance().font.width(clock.getMessage()));
date.update(now,locale);
var list=items("bulletins");
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
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")))));
date.setWidth(Minecraft.getInstance().font.width(date.getMessage()));
int left=Math.max(date.getRight()+8,screen.width/5),right=screen.width-Math.max(56,screen.width/5);
bulletin.setRectangle(Math.max(24,right-left),32,left,3);
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();
@@ -55,6 +55,7 @@ public final class CommunityScreen extends BlocodexFrame {
@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"));
@@ -100,22 +101,61 @@ public final class CommunityScreen extends BlocodexFrame {
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")) {
line(layout,w,text("replies"));
var replies = thread.getAsJsonObject("replies");
for (var item : replies.getAsJsonArray("entries")) message(layout,w,item.getAsJsonObject());
pages(layout,w,replies);
if (str(entry,"status").equals("open")) {
useDraft("reply:"+id);
var replyDraft=draft;
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
box.setCharacterLimit(1000); box.setValue(replyDraft.body); box.setValueListener(value -> replyDraft.body=value);
button(layout,100,"reply",() -> request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result -> {
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
DRAFTS.values().removeIf(value -> value==replyDraft); message="saved"; needsLoad=true; stale=true;
}));
}
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) {
@@ -209,6 +249,8 @@ public final class CommunityScreen extends BlocodexFrame {
}
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());
@@ -220,10 +262,13 @@ public final class CommunityScreen extends BlocodexFrame {
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")));
@@ -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(() -> {
@@ -17,7 +17,7 @@ final class PauseTextButton extends AbstractButton {
var font=Minecraft.getInstance().font;
if(height>24) {
var lines=font.split(getMessage(),width-10);int count=Math.min(2,lines.size());
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+(width-font.width(lines.get(i)))/2,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
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);
@@ -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;
@@ -0,0 +1,79 @@
package fr.koka.sanctuary.community;
import fr.koka.sanctuary.SanctuaryMod;
import java.nio.charset.StandardCharsets;
import java.sql.DriverManager;
import java.util.*;
import java.util.concurrent.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.level.storage.LevelResource;
/** Server-thread observations, bounded asynchronous persistence; no web requests touch the game thread. */
public final class ServerMetrics {
private static final Map<MinecraftServer,Session> SESSIONS=new HashMap<>();
private ServerMetrics() { }
public static void register() {
ServerLifecycleEvents.SERVER_STARTED.register(server -> {if(!server.isSingleplayer()) {var s=new Session(server);SESSIONS.put(server,s);s.capture(true);}});
ServerTickEvents.END_SERVER_TICK.register(server -> {var s=SESSIONS.get(server);if(s!=null && System.nanoTime()-s.lastCapture>=30_000_000_000L)s.capture(true);});
ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{var s=SESSIONS.get(server);if(s!=null)s.observe(handler.player);});
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {var s=SESSIONS.remove(server);if(s!=null)s.stop();});
}
private static boolean actor(ServerPlayer player) {
String name=player.getGameProfile().name();
return name.startsWith("Lab_") && player.getUUID().equals(UUID.nameUUIDFromBytes(("SanctuaryDuo:"+name).getBytes(StandardCharsets.UTF_8)));
}
private static final class Session {
final MinecraftServer server;
final ThreadPoolExecutor worker=new ThreadPoolExecutor(1,1,0L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(2),r->{var t=new Thread(r,"sanctuary-metrics");t.setDaemon(true);return t;});
final Map<String,Long> latest=new HashMap<>();
final Set<String> actors=new HashSet<>();
final long started=System.nanoTime()/1_000_000;
ServerMetricsLedger ledger;
CommunityConfig config;
boolean broken;
long lastCapture,lastSqlWarning;
Session(MinecraftServer server){this.server=server;}
void observe(ServerPlayer player) {
if(actor(player))actors.add(player.getStringUUID());
else latest.put(player.getStringUUID(),(long)player.getStats().getValue(Stats.CUSTOM.get(Stats.DEATHS)));
}
void capture(boolean running) {
lastCapture=System.nanoTime();
int online=0;
for(var player:server.getPlayerList().getPlayers()) {observe(player);if(!actor(player))online++;}
var values=Map.copyOf(latest);var excluded=Set.copyOf(actors);
long monotonic=lastCapture/1_000_000,wall=System.currentTimeMillis();int count=running?online:0;
try {worker.execute(()->persist(monotonic,wall,values,excluded,count,running));}
catch(RejectedExecutionException error){SanctuaryMod.LOGGER.warn("Server metrics worker busy; next sample will include elapsed time");}
}
void persist(long monotonic,long wall,Map<String,Long> values,Set<String> excluded,int online,boolean running) {
if(broken)return;
ServerMetricsLedger.Snapshot snapshot;
try {
if(ledger==null)ledger=new ServerMetricsLedger(server.getWorldPath(LevelResource.ROOT),started);
snapshot=ledger.sample(monotonic,wall,values,excluded,online,running);
} catch(Exception error) {broken=true;SanctuaryMod.LOGGER.error("Server metrics stopped; existing local data preserved ({})",error.getClass().getSimpleName());return;}
try {
if(config==null)config=CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
if(!config.mode().equals("database"))return;
String password=System.getenv(config.passwordEnv());if(password==null)throw new IllegalStateException("Missing metrics database credential");
var props=new Properties();props.setProperty("user",config.user());props.setProperty("password",password);props.setProperty("connectTimeout","2000");props.setProperty("socketTimeout","3000");
try(var c=DriverManager.getConnection(config.jdbcUrl(),props);var stmt=c.prepareStatement("INSERT INTO sanctuary_server_metrics (server_id,uptime_millis,deaths,online_players,updated_at,running) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE uptime_millis=VALUES(uptime_millis),deaths=VALUES(deaths),online_players=VALUES(online_players),updated_at=VALUES(updated_at),running=VALUES(running)")) {
stmt.setQueryTimeout(3);stmt.setString(1,config.serverId());stmt.setLong(2,snapshot.uptimeMillis());stmt.setLong(3,snapshot.deaths());stmt.setInt(4,snapshot.onlinePlayers());stmt.setLong(5,snapshot.updatedAt());stmt.setBoolean(6,snapshot.running());stmt.executeUpdate();
}
} catch(Exception error) {
if(lastSqlWarning==0 || wall-lastSqlWarning>=300_000){lastSqlWarning=wall;SanctuaryMod.LOGGER.warn("Server metrics publication unavailable; local measurements retained ({})",error.getClass().getSimpleName());}
}
}
void stop() {
capture(false);worker.shutdown();
try {if(!worker.awaitTermination(8,TimeUnit.SECONDS))worker.shutdownNow();}
catch(InterruptedException error){worker.shutdownNow();Thread.currentThread().interrupt();}
}
}
}
@@ -0,0 +1,56 @@
package fr.koka.sanctuary.community;
import com.google.gson.*;
import fr.koka.sanctuary.progression.AtomicJson;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
/** Additive ledger: elapsed process time, vanilla deaths, and explicitly excluded lab actors. */
public final class ServerMetricsLedger {
private static final Gson JSON = new Gson();
private final Path path;
private byte[] previous;
private long uptimeMillis;
private final Map<String,Long> deaths = new HashMap<>();
private final Set<String> excluded = new HashSet<>();
private long sampledAt;
public record Snapshot(long uptimeMillis, long deaths, int onlinePlayers, long updatedAt, boolean running) { }
public ServerMetricsLedger(Path root, long monotonicMillis) throws Exception {
path=root.resolve("data/sanctuary-server-metrics.json");
sampledAt=monotonicMillis;
previous=AtomicJson.read(path,4*1024*1024);
if(previous!=null) {
var data=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8));
AtomicJson.fields(data,"version","uptimeMillis","deaths","excluded");
var o=data.getAsJsonObject();
if(o.get("version").getAsInt()!=1 || (uptimeMillis=o.get("uptimeMillis").getAsLong())<0)throw new IllegalArgumentException("Metrics version or uptime");
for(var e:o.getAsJsonObject("deaths").entrySet()) {Community.uuid(e.getKey());long value=e.getValue().getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");deaths.put(e.getKey(),value);}
for(var e:o.getAsJsonArray("excluded"))excluded.add(Community.uuid(e.getAsString()));
}
var stats=root.resolve("stats");
if(Files.isDirectory(stats))try(var files=Files.list(stats)) {
for(var file:files.filter(f->f.getFileName().toString().matches("[0-9a-f-]{36}\\.json")).toList()) {
String id=Community.uuid(file.getFileName().toString().substring(0,36));
byte[] bytes=AtomicJson.read(file,8*1024*1024);
var o=AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();
var all=o.getAsJsonObject("stats");var custom=all==null?null:all.getAsJsonObject("minecraft:custom");
if(custom!=null && custom.has("minecraft:deaths")) {
long value=custom.get("minecraft:deaths").getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");
deaths.merge(id,value,Math::max);
}
}
}
}
public Snapshot sample(long monotonicMillis,long wallMillis,Map<String,Long> currentDeaths,Set<String> actors,int online,boolean running) throws Exception {
if(monotonicMillis<sampledAt || wallMillis<0 || online<0)throw new IllegalArgumentException("Metrics clock");
uptimeMillis=Math.addExact(uptimeMillis,monotonicMillis-sampledAt);sampledAt=monotonicMillis;
excluded.addAll(actors);
for(var e:currentDeaths.entrySet()) {Community.uuid(e.getKey());if(e.getValue()<0)throw new IllegalArgumentException("Deaths");deaths.merge(e.getKey(),e.getValue(),Math::max);}
excluded.forEach(deaths::remove);
var o=new JsonObject();o.addProperty("version",1);o.addProperty("uptimeMillis",uptimeMillis);o.add("deaths",JSON.toJsonTree(deaths));o.add("excluded",JSON.toJsonTree(excluded));
previous=AtomicJson.write(path,previous,JSON.toJson(o),4*1024*1024);
long total=0;for(long value:deaths.values())total=Math.addExact(total,value);
return new Snapshot(uptimeMillis,total,online,wallMillis,running);
}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.mixin.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.Options;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArgs;
import org.spongepowered.asm.mixin.injection.invoke.arg.Args;
@Mixin(Options.class)
public abstract class PlayerListKeyMixin {
@ModifyArgs(method = "<init>", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyMapping;<init>(Ljava/lang/String;ILnet/minecraft/client/KeyMapping$Category;)V"))
private void sanctuary$playerListDefault(Args args) {
if ("key.playerlist".equals(args.get(0))) args.set(1, InputConstants.KEY_U);
}
}
@@ -2439,5 +2439,10 @@
"sanctuary.community.task_expired": "Request expired. Please publish again.",
"sanctuary.community.own_notice": "You cannot follow your own request.",
"sanctuary.community.follow_limit": "You already follow 16 requests.",
"sanctuary.community.notice_full": "This request has no available places."
"sanctuary.community.notice_full": "This request has no available places.",
"sanctuary.community.follow_map_active": "Following: a map marker appears when a location is provided.",
"sanctuary.community.follow_map_help": "Replying does not subscribe you. Follow this request to display its map marker.",
"sanctuary.community.notice_full_help": "The single place is taken. You can reply, but cannot follow this request.",
"sanctuary.community.conversation_empty": "No replies yet.",
"sanctuary.community.request_details": "The request"
}
@@ -2439,5 +2439,10 @@
"sanctuary.community.task_expired": "La demande a expiré. Réessayez de publier.",
"sanctuary.community.own_notice": "Vous ne pouvez pas suivre votre propre demande.",
"sanctuary.community.follow_limit": "Vous suivez déjà 16 demandes.",
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible."
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible.",
"sanctuary.community.follow_map_active": "Demande suivie : son repère apparaît sur la carte si un lieu est indiqué.",
"sanctuary.community.follow_map_help": "Répondre ne vous abonne pas. Suivez la demande pour afficher son repère sur la carte.",
"sanctuary.community.notice_full_help": "La place unique est occupée. Vous pouvez répondre, mais pas suivre cette demande.",
"sanctuary.community.conversation_empty": "Aucune réponse pour le moment.",
"sanctuary.community.request_details": "La demande"
}
@@ -289,7 +289,8 @@
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess",
"client.PauseAtlasServerMixin",
"client.CommunityPauseMixin"
"client.CommunityPauseMixin",
"client.PlayerListKeyMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,28 @@
package fr.koka.sanctuary.community;
import java.nio.file.*;
import java.util.*;
public final class ServerMetricsSmoke {
public static void main(String[] args) throws Exception {
Path root=Files.createTempDirectory("sanctuary-metrics-test");
String player="11111111-1111-4111-8111-111111111111",bot="22222222-2222-4222-8222-222222222222";
Files.createDirectories(root.resolve("stats"));
Files.writeString(root.resolve("stats/"+player+".json"),"{\"stats\":{\"minecraft:custom\":{\"minecraft:deaths\":12}}}");
var ledger=new ServerMetricsLedger(root,100);
var first=ledger.sample(3100,10000,Map.of(player,13L,bot,9L),Set.of(bot),1,true);
check(first.uptimeMillis()==3000 && first.deaths()==13 && first.onlinePlayers()==1,"imports offline deaths, counts current deaths, excludes actors");
ledger.sample(4100,11000,Map.of(),Set.of(),0,false);
var restarted=new ServerMetricsLedger(root,900000);
var next=restarted.sample(901000,9000000,Map.of(player,13L),Set.of(),1,true);
check(next.uptimeMillis()==5000 && next.deaths()==13,"downtime excluded and deaths not counted twice");
var death=restarted.sample(902000,9001000,Map.of(player,14L),Set.of(),1,true);
check(death.deaths()==14,"new death increments cumulative total");
Path file=root.resolve("data/sanctuary-server-metrics.json");Files.writeString(file,"corrupt");
try{restarted.sample(903000,9002000,Map.of(),Set.of(),0,false);throw new AssertionError("overwrite");}catch(java.io.IOException expected){}
check(Files.readString(file).equals("corrupt"),"external edit preserved");
try{new ServerMetricsLedger(root,0);throw new AssertionError("reset");}catch(IllegalArgumentException expected){}
System.out.println("SERVER_METRICS161_PASS");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.159"
version = "beta.165"
pack-format = "packwiz:1.1.0"
[index]
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Local, isolated duo lab. No personal saves, accounts or launcher settings touched."""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import subprocess
import time
import uuid
import html
ROOT = Path(__file__).resolve().parents[1]
LAB = ROOT / "build" / "duo"
def create(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(text)
def prepare(args):
server = LAB / "server"
create(server / "server.properties", """server-ip=127.0.0.1
server-port=25575
online-mode=false
white-list=false
enforce-secure-profile=false
enable-query=false
enable-rcon=false
level-name=duo-flat-160
level-type=sanctuary_test:flat
level-seed=160
gamemode=creative
difficulty=peaceful
max-players=6
view-distance=4
simulation-distance=3
pause-when-empty-seconds=30
spawn-protection=0
motd=Sanctuary DUO — local test lab
""")
create(server / "eula.txt", "eula=true\n")
config = {"mode": args.storage, "serverId": args.server_id or str(uuid.uuid4()),
"jdbcUrl": args.jdbc, "user": "root", "passwordEnv": "SANCTUARY_COMMUNITY_DB_PASSWORD"}
create(server / "config/sanctuary-community.json", json.dumps(config, indent=2) + "\n")
create(LAB / "client/config/sanctuary-shaders.json", '{"enabled":false}\n')
create(LAB / "client/options.txt", "renderDistance:6\nsimulationDistance:4\nmaxFps:30\nenableVsync:true\nguiScale:2\nlang:fr_fr\npauseOnLostFocus:false\n")
print("Prepared", LAB, "— existing files preserved. Server: 127.0.0.1:25575")
def launch(args):
java_home = os.environ.get("JAVA_HOME")
if not java_home:
java_home = subprocess.check_output(["/usr/libexec/java_home", "-v", "25"], text=True).strip()
java = str(Path(java_home) / "bin/java")
argfile = LAB / (args.command + ".args")
if not argfile.is_file():
raise SystemExit("Run ./gradlew :sanctuary-test:exportDuoLaunch first.")
if not 512 <= args.memory <= (2048 if args.command == "server" else 3072):
raise SystemExit("Memory must be 512–2048 MiB (server) or 512–3072 MiB (client).")
# JVM overrides must precede the main class, therefore rewrite a temporary argfile.
lines = argfile.read_text().splitlines()
lines = [f'"-Xmx{args.memory}m"' if line.startswith('"-Xmx') else line for line in lines]
if args.command == "client":
lines += ['"--quickPlayMultiplayer"', '"127.0.0.1:25575"']
runtime = LAB / (args.command + "-runtime.args")
runtime.write_text("\n".join(lines) + "\n")
os.chdir(LAB / args.command)
print(f"Starting {args.command}: heap cap {args.memory} MiB; native/GPU memory is additional.", flush=True)
os.execv(java, [java, "@" + str(runtime)])
def record(args):
if not 1 <= args.seconds <= 300:
raise SystemExit("Sessions are limited to 1–300 seconds.")
windows = json.loads(subprocess.check_output(["swift", str(ROOT / "scripts/minecraft_windows.swift")], text=True))
if args.window not in [window["id"] for window in windows]:
raise SystemExit("This ID is not a visible Minecraft window. Run the windows command first.")
# Explicit window ID prevents accidentally recording another screen or app.
session = LAB / "sessions" / dt.datetime.now().strftime("%Y%m%d-%H%M%S")
session.mkdir(parents=True)
create(session / "notes.md", "# Séance duo\n\nScène :\nAttendu :\nObservations ergonomiques :\nVérifications fonctionnelles :\nDécisions :\n")
(session / "manifest.json").write_text(json.dumps({"window": args.window, "duration": args.seconds,
"interval": 2, "audio": False, "format": "timestamped PNG sequence", "started": dt.datetime.now().astimezone().isoformat()}, indent=2))
print("Recording selected Minecraft window only, no audio:", session, flush=True)
started = time.monotonic()
try:
while time.monotonic() - started < args.seconds:
elapsed = time.monotonic() - started
output = session / f"{elapsed:07.2f}.png"
subprocess.run(["/usr/sbin/screencapture", "-x", "-o", "-l", str(args.window), str(output)], check=True)
time.sleep(2)
except KeyboardInterrupt:
pass
frames = sorted(session.glob("*.png"))
rows = "\n".join(f'<figure><figcaption>{html.escape(frame.stem)} s</figcaption><a href="{frame.name}"><img loading="lazy" src="{frame.name}"></a></figure>' for frame in frames)
(session / "index.html").write_text('<!doctype html><meta charset="utf-8"><title>Séance Sanctuary</title>'
'<style>body{background:#171c1a;color:#f2f2e7;font:16px system-ui;margin:24px}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px}figure{margin:0}img{width:100%}</style>'
'<h1>Séance Sanctuary</h1><p>Une capture toutes les deux secondes. Cliquer pour voir en grand.</p><main>' + rows + '</main>')
print("Session saved:", session)
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
setup = sub.add_parser("prepare")
setup.add_argument("--storage", choices=["file", "database"], default="file")
setup.add_argument("--server-id")
setup.add_argument("--jdbc", default="jdbc:mariadb://127.0.0.1:33077/sanctuary_community_tasks158_interop")
sub.add_parser("windows", help="List only visible Minecraft window IDs")
for command, memory in [("server", 768), ("client", 2048)]:
item = sub.add_parser(command)
item.add_argument("--memory", type=int, default=memory)
capture = sub.add_parser("record")
capture.add_argument("--window", type=int, required=True, help="Verified Minecraft window ID; never the desktop")
capture.add_argument("--seconds", type=int, default=120)
args = parser.parse_args()
if args.command == "prepare":
prepare(args)
elif args.command == "windows":
subprocess.run(["swift", str(ROOT / "scripts/minecraft_windows.swift")], check=True)
elif args.command == "record":
record(args)
else:
launch(args)
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
import CoreGraphics
import Foundation
// Only list Minecraft/Java game windows, never titles from other applications.
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
let matches = windows.compactMap { window -> [String: Any]? in
let owner = window[kCGWindowOwnerName as String] as? String ?? ""
let title = window[kCGWindowName as String] as? String ?? ""
guard ["java", "Minecraft"].contains(owner), title.hasPrefix("Sanctuary - ") || title.hasPrefix("Minecraft") else { return nil }
return ["id": window[kCGWindowNumber as String] ?? 0, "pid": window[kCGWindowOwnerPID as String] ?? 0, "title": title]
}
let data = try JSONSerialization.data(withJSONObject: matches, options: [.prettyPrinted, .sortedKeys])
print(String(data: data, encoding: .utf8)!)