Compare commits

...
4 Commits
Author SHA1 Message Date
koka bdaad0c940 Add community search, responsive gallery and followed notice map markers
Build Sanctuary / build (push) Waiting to run
2026-09-23 01:20:17 +02:00
koka 19aa8054a5 beta.158: redesign community cards and add descriptive notice tracking
Build Sanctuary / build (push) Waiting to run
2026-09-23 00:24:47 +02:00
koka 9791d178ab beta.157: require article photos with a scrollable screenshot gallery
Build Sanctuary / build (push) Waiting to run
2026-09-22 22:43:19 +02:00
koka 32158e2e05 Déplace la reprise du jeu sous la colonne communautaire (beta.156)
Build Sanctuary / build (push) Waiting to run
2026-09-22 21:34:41 +02:00
40 changed files with 1986 additions and 124 deletions
+24
View File
@@ -1,5 +1,29 @@
# Sanctuary
## beta.159 — recherche communautaire et galerie adaptable (pack local)
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
année dans les dates et galerie adaptée au GUI avec grand aperçu.
Repères « ! » et infobulles sur la carte pour les demandes suivies géolocalisées.
[Résultat et vérifications](docs/community-search-beta159.md).
## beta.158 — cartes communautaires et demandes suivies (pack local)
Grilles à deux colonnes, articles photo/texte, annonces colorées avec visages,
coffres descriptifs et suivi persistant fichier/MariaDB. Sélecteur photo compact.
[Contrat et vérifications](docs/community-cards-beta158.md).
## beta.157 — photo obligatoire dans la Gazette (pack local)
Galerie de captures défilante, aperçu et assignation à un article. Publication
avec photo obligatoire, stockage fichier ou MariaDB partagé avec le site.
[Contrat de migration et vérifications](docs/gazette-photos-beta157.md).
## beta.156 — reprendre la partie sous la colonne communautaire (pack local)
Le bouton Reprendre la partie est aligné sous la Gazette et le tableau
d’affichage, en bas à droite. [Vérifications](docs/pause-resume-beta156.md).
## beta.155 — nouvelle structure du menu pause (pack local)
Navigation Sanctuary à gauche, carte centrale, Gazette et demandes défilantes
+98
View File
@@ -0,0 +1,98 @@
# beta.158 — cartes communautaires et demandes suivies
## Contrat v3 et migration
Les règles de génération et les inventaires Minecraft ne changent pas. Les coffres
sont des sélections descriptives : aucun objet n'est prélevé, réservé, livré ou
créé. Les échanges et la répartition des récompenses sont organisés entre joueurs.
Il n'y a ni gagnant automatique ni validation automatique d'une livraison.
Une annonce peut porter `task` : position facultative (dimension, x/y/z), liste
`materials` de 27 identifiants d'objets et quantités (1 à 999999), participation
`shared` ou `single`, récompense `none`, `items` (27 sélections) ou `custom` (240
caractères). Les catégories info/work/need/event gardent leurs identifiants.
Le serveur Minecraft valide aussi les objets et dimensions contre son registre.
Les données du site sont descriptives et restent lisibles si un objet manque côté
client ; les identifiants inconnus ne créent jamais d'objet.
Les abonnements sont authentifiés, idempotents, au plus 16 par joueur et 64 par
annonce. Une annonce single accepte un seul abonné. L'auteur ne s'abonne pas à sa
propre annonce. Fermeture et masquage retirent le suivi actif ; réouvrir autorise
à nouveau l'abonnement. Les abonnements existants ne sont pas supprimés par la
fermeture : un joueur peut se désabonner. Changer shared vers single est refusé
si plusieurs abonnés sont présents. La liste côté HUD est issue du serveur ; les
notifications affichent les trois suivis ouverts les plus récents, avec le lieu
et un rappel des matériaux. La fermeture est annoncée, sans attribuer de gain.
Fichier : ajout du champ task et de la collection followers ; lecture v1/v2 sans
écriture. À la première écriture, backup exact `.v1.bak` ou `.v2.bak` sans
écrasement puis écriture atomique en v3. Le plafond 64 Mio reste appliqué. Aucun
monde personnel n'est migré pendant le développement. Retour arrière : serveur
arrêté, restauration du backup et de la version logicielle correspondante.
MariaDB : arrêter les écritures du mod et du site, sauvegarder, appliquer la
migration v2→v3 (SQL livré ou Laravel), puis mettre à jour les deux applications.
Colonne task nullable, tables followers et subscribers (verrous par joueur).
Aucune migration automatique par le mod ; aucune suppression des anciens posts.
Les anciens articles et annonces restent lisibles. Rollback uniquement par
restauration explicite ; pas de suppression automatique des nouvelles données.
La Gazette et le tableau utilisent une grille à deux colonnes. Les aperçus de
photo sont bornés à 6 Kio binaires par carte ; les listes ne diffusent pas les
photos complètes. L'intendance reste affichée dans l'en-tête, sans accès public
à son historique ni à son éditeur ; sa gestion est réservée au panneau admin.
Le panneau Web est complété par `/sanctuary community admin` côté jeu, réservé
aux opérateurs par le serveur, notamment pour les installations en mode fichier.
Les métadonnées de l’article (visage, nom, date) sont sur la rangée du bouton
« … », juste sous l’encadré photo/texte. La galerie est plafonnée à 420 unités
de largeur et 180 de hauteur ; l’aperçu du formulaire est limité à 160 × 72.
Le bouton de liaison du compte Web disparaît des écrans communautaires en jeu.
Les sondages restent une piste, sans fonctionnalité livrée dans cette version.
## Vérifications
Vérifications ciblées du 23 septembre 2026 :
- Stockage fichier et MariaDB : métadonnées, inscription exclusive concurrente,
idempotence, limite de 16 suivis, fermeture/réouverture, désabonnement,
refus shared→single occupé, masquage et redémarrage. Migration v1/v2 avec
conservation exacte des backups.
- Client natif Vulkan : français/anglais, échelles GUI 2/3/4, grilles de deux
colonnes, défilement et chargement des cartes suivantes sans retour en haut,
galerie compacte, photo obligatoire, publication, coffre de sélection,
position courante, récompense personnalisée, abonnement et HUD.
- Vérification du déplacement visage/nom/date sous l’article, à côté de « … ».
- Accès opérateur au panneau d’intendance en jeu : refus au joueur ordinaire,
ouverture et publication après activation des permissions natives.
- Régression du menu pause : colonnes, boutons, carte, défilements indépendants,
longs articles/annonces et réponse, sous Vulkan en FR/EN et GUI 2/3/4.
- Site : 23 tests / 131 assertions, dont droits, migrations v3 sans perte ni
rétrogradation, validation des matériaux et inscription exclusive.
- Interopérabilité MariaDB réelle Java→PHP→Java : photo, édition, réponse,
identité vérifiée, demande et abonnement Web relus par le mod (18 assertions PHP).
- Ressources de shaders : 29 fichiers identiques au JAR beta.157, qui conservait
le socle beta.151. Les anciens JAR et MRpack sont conservés.
Suite générale : `./gradlew check build assemblePack` exécuté ; 252 GameTests,
229 réussites et les mêmes 23 échecs identifiés dans la beta.157, aucun nouvel ID.
Comparaison conservée dans `build/cards158-server-failures.json`. Les autres
contrôles, le build et l’assemblage passent avec cette suite déjà vérifiée exclue :
`./gradlew check build assemblePack :sanctuary:runClientGameTest -x :sanctuary:runGameTest`
avec les propriétés client Cards158/Vulkan et la base MariaDB de développement.
Le dernier parcours confirme aussi le refus des quantités invalides et la
conservation des 17 bûches et 3 diamants réels après publication descriptive.
Export packwiz réussi ; version interne beta.158 et JAR embarqué vérifiés.
Captures finales : `build/cards158-database-screens/` ; captures fichier :
`build/cards158-file-screens/`. Les grilles, la galerie et le pied d’article
ont été inspectés visuellement. Journaux : `build/beta158-*.log`.
Site local : commit `0dcab21` sur
`codex/community-quests`. Aucun déploiement dans une installation personnelle,
aucune publication du canal packwiz ni modification d’un monde existant.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.158.jar` — SHA-256 `0590320dcece7f94543ab17e99fb23ac37cc3e2578499aa6e80ad801212675e8`.
- `build/Sanctuary-beta.158.mrpack` — SHA-256 `9c1e556ddce3094febf92631c8a1c8cc0ec56971b332cd9c2d7448f2f9931b89`.
+80
View File
@@ -1,5 +1,8 @@
# Communauté Sanctuary — contrat v1
> Depuis beta.158, le schéma actif est la v3 : photos et demandes suivies.
> Appliquer les migrations avant de connecter les deux applications.
Ticket beta.154, branche `codex/community-beta154`, socle beta.151 `b97ec6c`.
Ce contrat décrit le comportement cible et les échanges implémentés par ce ticket.
La note de livraison indique séparément les vérifications effectivement réussies.
@@ -158,3 +161,80 @@ Sur le site, le rendu par UUID utilise le prestataire mc-api.io déjà employé
par la page de compte ; chargement différé, sans référent, initiale locale en
cas d’erreur. Aucune image ni URL arbitraire n’entre dans le contrat des posts.
Documentation du rendu : https://mc-api.io/docs (GET `/render/{uuid}?size=64`).
## Évolution v2 (beta.157)
Le champ photo et sa migration explicite, avec conservation des anciens articles,
sont décrits dans [Photos de Gazette](gazette-photos-beta157.md).
Pour une nouvelle base partagée avec le site, utiliser les migrations Laravel.
Pour une base autonome administrée par SQL, appliquer schema-v1.sql puis
schema-v1-to-v2.sql avant de connecter le mod beta.157.
## Évolution v3 — demandes suivies (beta.158)
Annonce : `task` contient `location` nullable (`dimension`, `x`, `y`, `z`),
`materials` et `rewards` (27 objets maximum, `item` namespacé de 100 caractères
maximum, `count` de 1 à 999999), `audience` shared/single, `reward` none/items/custom
et `customReward` (240 caractères). Les coffres sont descriptifs : échanges
et partage des récompenses entre joueurs, sans transaction d'inventaire.
Les tables `sanctuary_community_followers` et `sanctuary_community_subscribers`
portent les abonnements et les verrous par joueur. Maximum 16 abonnements/joueur,
64/annonce, un seul en mode single ; pas d'auto-abonnement de l'auteur.
Le masquage supprime les abonnements ; la fermeture les conserve mais suspend
le suivi actif. Un passage en single est refusé au-delà d'un abonné.
Arrêter les écritures et sauvegarder avant migration. Installation SQL neuve :
schema-v1.sql, schema-v1-to-v2.sql, puis schema-v2-to-v3.sql du mod. Sur le site,
`php artisan migrate` applique l'équivalent et reconnaît les tables déjà créées.
Le compte JDBC nécessite SELECT/INSERT/DELETE sur followers et SELECT/INSERT
sur subscribers, en plus des permissions v2. Aucun DDL automatique par le mod.
L'intendance se gère dans le panneau admin ; seul son dernier message reste
public en en-tête. La liaison de compte se fait avec le code généré sur le site
et `/sanctuary community link <code>` en jeu (serveur authentifié requis).
Les anciens boutons de liaison et d'historique public de l'intendance disparaissent.
En mode fichier, le panneau d’intendance en jeu s’ouvre avec
`/sanctuary community admin` ; le serveur exige les permissions opérateur.
## Évolution beta.159 — recherche et lecture de l’intendance
Le schéma reste **v3** : aucune migration de données. Cette évolution remplace
la restriction de lecture publique de beta.158 : les onglets Gazette, Tableau
et Serveur permettent de lire leurs historiques. La publication et la gestion
de l’intendance restent dans l’administration, sans réponse ni abonnement.
`list` et `cards` acceptent une recherche facultative dans `Request.body` (120
caractères maximum, vide par défaut). Le site utilise le paramètre GET `q`,
conservé dans la pagination. Recherche littérale de sous-chaîne dans le nom de
l’auteur, le titre **ou** le corps, sans distinction de casse, avant pagination.
Les accents restent significatifs et `%` / `_` sont des caractères ordinaires.
Le filtre reste limité au serveur et à la rubrique sélectionnés ; les contenus
masqués restent exclus. Les épingles gardent leur priorité. Le client attend
300 ms après la frappe et ignore les réponses correspondant à un ancien filtre.
Les pages restent bornées à 10 publications ; aucune recherche dans les réponses.
Les dates des publications affichent l’année (UTC). Le menu pause affiche la date
complète dans la langue et le fuseau local du joueur. La galerie utilise la
surface disponible du GUI, adapte ses colonnes et conserve ses pages de 24
captures défilantes. Le clic ouvre un aperçu dans cette surface, en conservant
les proportions et les boutons Retour / Assigner. Le formulaire conserve son
aperçu compact. Les PNG sources ne sont pas modifiés.
Les demandes suivies ouvertes qui ont une position affichent un `!` sur la carte
de pause et le grand atlas, dans la dimension correspondante. Elles ne révèlent
pas le terrain et ne chargent aucun chunk. L’infobulle montre titre, auteur,
résumé, coordonnées, matériaux et récompense ; les listes longues sont abrégées
pour rester dans le GUI. Le clic ouvre l’annonce. « Voir sur la carte » centre
l’atlas sur son lieu dans la dimension courante, sans téléportation. Les repères
suivent tous les abonnements, indépendamment des trois suivis visibles dans le
HUD ; ils disparaissent au désabonnement, à la clôture ou au masquage. Un
rafraîchissement des suivis reste actif lorsque la carte ou la pause est ouverte
(période de cinq secondes, différée si une autre requête est en cours).
Le message réseau `following` ajoute `summary`, extrait littéral du corps limité
à 240 caractères Unicode plus une ellipse. Le texte intégral et les photos ne
sont pas diffusés dans ce message. Aucun champ persistant supplémentaire.
+81
View File
@@ -0,0 +1,81 @@
# beta.159 — recherche communautaire et galerie adaptable
Branche `codex/community-search-gallery-beta159`, socle beta.158.
## Résultat
Une barre de recherche filtre les publications par auteur, titre ou texte côté
serveur, avant pagination. Le filtre accompagne les pages suivantes, conserve
les épingles et se réinitialise en effaçant le champ. Les réponses aux anciennes
recherches sont ignorées et le focus de saisie est conservé.
Les trois onglets Gazette / Tableau / Serveur donnent accès aux publications de
chaque rubrique. L’en-tête d’intendance de la pause ouvre aussi son historique.
La lecture est publique ; la publication et la gestion des messages du serveur
restent réservées au panneau administrateur, même pour un opérateur visitant
l’écran public. Les messages du serveur n’acceptent ni réponses ni abonnements.
L’année figure dans la date de pause et les métadonnées des publications. La
galerie utilise l’espace disponible aux différentes échelles de GUI et tailles
de fenêtre. Les captures restent paginées par 24 avec défilement. Cliquer une
capture ouvre un grand aperçu avec les commandes de retour et d’assignation.
La photo assignée reste compacte dans le formulaire et les brouillons sont
conservés lors du retour. Aucune modification des PNG originaux.
Les annonces suivies géolocalisées portent un « ! » sur les deux cartes.
L’infobulle fournit titre, auteur, résumé, coordonnées, matériaux et récompense
sans ouvrir la discussion ; le clic reste disponible. Un bouton dans l’annonce
centre le grand atlas sur sa position, dans la dimension courante. Aucune
téléportation ni révélation du terrain : seules les coordonnées publiées sont
projetées. Les annonces fermées, masquées, sans lieu ou non suivies ne portent
pas de repère. Les longues infobulles sont abrégées pour tenir dans le GUI.
Le site suit le même contrat de recherche et de lecture. Schéma v3 inchangé,
aucune migration de sauvegarde ni de base. Libellés français et anglais.
## Vérifications
- Stockage fichier et MariaDB : recherche par auteur et corps, casse et accents,
filtre avant pagination, épingles conservées, caractères `%` / `_` littéraux,
rubrique et serveur isolés, contenus masqués exclus, borne de 120 caractères.
- Client natif Minecraft 26.3 / Java 25 / Vulkan : galerie et aperçu en FR/EN,
GUI 2/3/4 et fenêtres 1280×960 / 1920×1080 ; retour au brouillon, assignation,
publication, listes défilantes et chargement des pages suivantes.
- Recherche en jeu au-delà de la première page, filtre par joueur, effacement,
conservation du focus et rejet d’une réponse correspondant à l’ancien filtre.
- Lecture des messages serveur par un joueur ordinaire, aucun contrôle d’écriture
dans les pages publiques, y compris pour l’opérateur ; administration inchangée.
- Carte : projection dans la dimension correspondante, titre/auteur/résumé,
matériaux et récompense au survol ; ouverture de la demande par clic sur les
deux cartes ; retrait immédiat du repère après désabonnement.
- Date avec année en FR/EN, GUI 2/3/4, sans chevauchement de l’intendance.
- Site : 24 tests / 157 assertions sur SQLite puis MariaDB de développement,
formatage Pint réussi. Commit local `e70e78d`, branche `codex/community-search`.
La commande complète `./gradlew :sanctuary:runClientGameTest check build assemblePack`
avec les propriétés client Cards158, QuickTests et Vulkan a exécuté 252 GameTests :
229 réussites et les mêmes 23 échecs que beta.158. Aucun test serveur supprimé
ou modifié. La comparaison est conservée dans `build/search159-server-failures.json`
et le journal dans `build/beta159-check-build.log`.
Les contrôles restants, la compilation et `assemblePack` passent avec la suite
serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 133 tâches, journal
`build/beta159-release.log`. Le parcours client final passe en mode MariaDB,
puis en mode fichier (`build/beta159-file-client.log`). Les repères ont été
vérifiés avec et sans terrain d’atlas débloqué ; les infobulles restent dans la
fenêtre aux GUI 2/3/4. Captures représentatives inspectées :
`build/search159-file-screens/` et `build/search159-database-screens/`.
L’export packwiz est vérifié : intégrité ZIP, version beta.159, JAR embarqué
identique au JAR de livraison. Les 29 ressources de shaders sont identiques à
beta.158, qui conserve le socle beta.151. Les anciens JAR et MRpack beta.154 à
beta.158 restent présents. Aucun monde personnel, déploiement de serveur, canal
packwiz ou instance Prism n’est modifié.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.159.jar` — SHA-256 `c30d57a5f37e0a5c9dbb387ca831ce9e5c15a1dfe4cb55af0af20a376e862576`.
- `build/Sanctuary-beta.159.mrpack` — SHA-256 `868932d88bc241bfb526a7c5e4054009e247658bd0784b7141d82017edcc2823`.
+96
View File
@@ -0,0 +1,96 @@
# beta.157 — photo obligatoire et galerie de captures
## Contrat de migration communautaire v2
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
leur prochaine modification exige une photo. Avis, intendance et réponses restent
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
référence à un chemin local, d'URL distante ou de média orphelin persistant.
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
de restaurer la copie v1 et perd les changements communautaires postérieurs.
Ne pas restaurer pendant que le serveur tourne.
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
par la préparation de cette livraison.
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
seul le détail d'un article les contient. Le plafond des réponses
passe à 262 144 caractères pour une photo et une discussion complète ; les
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
anciens clients ne peuvent plus créer d'article sans photo.
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
Les captures illisibles restent visibles mais désactivées. Limites sources :
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
du dossier `screenshots` de son instance.
## Vérifications
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
désactivé sans photo, envoi fragmenté, photo visible et relecture après
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
copie de migration exacte, fragments hors ordre/incomplets/expirés.
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
anciennes publications et conservation après modification.
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
modification et réponse Web relues en Java, article/photo du site décodés par
Java. Migration Laravel et alternative SQL testées sur bases de développement.
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
indépendants, pagination, article et avis longs, réponse, options et reprise :
réussie avec les articles munis d'une photo.
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
client natif du menu pause), 2 min 56 s.
Les captures d'essai sont des mires générées dans une instance de développement,
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
`9626e95`, sans push ni publication. Logs de développement :
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
`build/photos157-final-build.log`.
## Artefacts locaux
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
Reçu complet : `build/photos157-validation.json`.
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
+33
View File
@@ -0,0 +1,33 @@
# PAUSE-156 — reprendre la partie sous la colonne droite
Branche `codex/pause-resume-right-beta156`, livraison locale `beta.156`.
Suite de [PAUSE-155](pause-redesign-beta155.md).
Le bouton natif Reprendre la partie se trouve en bas à droite, sous la colonne
Gazette / tableau, avec le même alignement et la même largeur. Il conserve son
action native et les libellés français / anglais de Minecraft.
La modification concerne uniquement sa géométrie. Les options restent en bas
à gauche ; les publications, le stockage, la carte et les shaders conservent
leur fonctionnement de beta.155. Aucune migration ni installation personnelle.
## Vérifications
Le scénario client Vulkan existant passe en français et anglais, GUI 2/3/4 à
1280 × 960 : alignement du bouton avec la colonne droite, largeur identique,
absence de chevauchement avec le tableau, reprise effective du jeu. Les
interactions communautaires du scénario passent également en mode fichier.
Log : `build/pause156-client.log`, aperçu `build/pause156-preview.png`.
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit.
Export packwiz, intégrité ZIP, versions du mod / pack et contenu du JAR
embarqué vérifiés. Les shaders sont inchangés et les artefacts beta.155
conservés avec leurs empreintes. Logs : `build/pause156-build-pack.log`,
`build/pause156-export.log`, reçu `build/pause156-artifacts.json`.
La suite serveur complète de beta.155 compte 229 réussites / 252, avec les
23 échecs historiques. Elle n’est pas relancée pour ce déplacement de bouton.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.156.jar` — SHA-256 `c325a757cff6063fe3702e755a57a3abc13ad86e071762354a3c449504d51c6f`.
- `build/Sanctuary-beta.156.mrpack` — SHA-256 `0537273dbfecb071d481f3f22982fbbb9a8e8a75581e38128ded87926ee699d0`.
+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.155
pack_version=beta.155
mod_version=beta.159
pack_version=beta.159
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+6
View File
@@ -258,6 +258,12 @@ tasks.named('processGametestResources') {
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def cards158Client = providers.gradleProperty('sanctuaryCards158ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCards158ClientTests', cards158Client)
if (cards158Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community158ClientChecks') }
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause155ClientTests', pause155Client)
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
@@ -69,7 +69,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.waitTicks(25);
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.DRAFTS.computeIfAbsent("new:article",unused->new CommunityClient.Draft()).photo=testPhoto(); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
@@ -80,6 +80,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> m.gui.setScreen(null));
} finally { intro.set(false); }
}
static String testPhoto() {try {return fr.koka.sanctuary.community.CommunityPhoto.encode(new java.awt.image.BufferedImage(16,9,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}catch(Exception e){throw new AssertionError(e);}}
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
@@ -0,0 +1,212 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Screenshot selection and article round trip through actual client/server packets. */
public final class Community158ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
try {
Files.createDirectories(folder.get());
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
}catch(Exception e){throw new AssertionError(e);}
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
var noticeId=UUID.randomUUID().toString();
net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents.SERVER_STARTED.register(server->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var author=new Community.Actor(UUID.randomUUID().toString(),"Voisin",true);
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Annonce officielle159","Le serveur vous souhaite la bienvenue.",0));
for(int i=0;i<13;i++) {
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","article","info","Gazette "+i,"Le village raconte. ".repeat(90)+(i==0?" Unique159":""),0,testPhoto()));
store.change(author,new Community.Change("create",i==0?noticeId:UUID.randomUUID().toString(),"","notice",List.of("info","event","work","need").get(i%4),"Demande "+i,"Rendez-vous au village. ".repeat(40),0,null,i==0?new CommunityTask(new CommunityTask.Location("minecraft:overworld",0,64,0),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"shared","custom",List.of(),"Un repas au village"):CommunityTask.EMPTY));
}
}catch(Exception error){throw new AssertionError(error);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cards158 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().invulnerable=true;player.setExperienceLevels(20);
check(fr.koka.sanctuary.progression.ProgressionService.purchase(player,"atlas",fr.koka.sanctuary.progression.ProgressEffects.get(player).revision()),"Atlas unlocked through progression");
fr.koka.sanctuary.progression.ProgressionService.send(player,"");
for(int z=-2;z<=2;z++)for(int x=-2;x<=2;x++) {
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(player.level(),player.chunkPosition().x()+x,player.chunkPosition().z()+z);
if(tile!=null)fr.koka.sanctuary.atlas.AtlasService.observations(player).observe(tile);
}
});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(int windowWidth:List.of(1280,1920)) {
c.runOnClient(m->{
m.getWindow().setWindowed(windowWidth,windowWidth==1280?960:1080);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
press(m.gui.screen(),"sanctuary.community.photo_choose");
});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->{
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
if(a.maxScrollAmount()>0){s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);}
var photos=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).toList();
check(photos.stream().mapToInt(AbstractWidget::getRight).max().orElseThrow()>s.width*0.8,"Gallery uses GUI width");
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("search159-gallery-"+locale+"-"+scale+"-"+windowWidth+(database?"-database":"-file"));
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.runOnClient(m->{var s=m.gui.screen();var image=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow();check(image.getWidth()>s.width*0.8&&image.getHeight()>s.height*0.5,"Preview fills available GUI");check(button(s,"photo_assign").getBottom()<s.height-30,"Preview controls remain visible");});
c.takeScreenshot("search159-preview-"+locale+"-"+scale+"-"+windowWidth);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.screenshots_back"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&button(m.gui.screen(),"previous").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("cards158-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
c.runOnClient(m->{var article=widgets(m.gui.screen()).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();var face=widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).findFirst().orElseThrow();check(face.getY()>=article.getBottom(),"Author metadata below the article");check(button(m.gui.screen(),"actions").getY()==face.getY(),"Author metadata next to ellipsis");check(widgets(m.gui.screen()).anyMatch(w->w instanceof AbstractWidget widget && widget.getMessage().getString().contains("/"+java.time.Year.now()+" ")),"Article date includes year");});
c.takeScreenshot("cards158-article"+(database?"-database":"-file"));
world.getServer().runOnServer(s->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var posts=store.list("article","",0).entries();check(posts.size()==10&&posts.getFirst().title().equals("Une journée au village"),"Newest publication among seeded cards");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
}catch(Exception e){throw new AssertionError(e);}});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(String kind:List.of("article","notice")){
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());CommunityClient.open(new PauseScreen(true),kind,"",false);});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->{var screen=m.gui.screen();var cards=widgets(screen).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).toList();
check(cards.get(0).getY()==cards.get(1).getY()&&cards.get(0).getRight()<cards.get(1).getX(),"Two-column grid");
check(cards.get(2).getY()>cards.get(0).getY(),"Rows continue vertically");
check(!has(screen,"sanctuary.community.link")&&has(screen,"sanctuary.community.server_tab"),"Public navigation restores read-only server history");
check(widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Grid scrolls");
});
c.takeScreenshot("cards158-grid-"+kind+"-"+locale+"-"+scale+(database?"-database":"-file"));
}
c.runOnClient(m->{var a=widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();a.setScrollAmount(a.maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.scrollAmount()>0),"Appending cards retains scroll"));
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"article","",false);edit(m.gui.screen()).setValue("unique159");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 0"),"Search finds body beyond first page");edit(m.gui.screen()).setValue("VoISin");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.more"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->{edit(m.gui.screen()).setValue("absent159");m.gui.screen().setFocused(edit(m.gui.screen()));for(int tick=0;tick<12;tick++)m.gui.screen().tick();check(CommunityClient.pending(),"Old search is in flight");edit(m.gui.screen()).setValue("Gazette 12");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 12"),"Latest search wins over prior pending request");check(edit(m.gui.screen()).isFocused(),"Search retains keyboard focus");});
c.takeScreenshot("search159-filtered");
c.runOnClient(m->edit(m.gui.screen()).setValue(""));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.server_tab"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public server listing is read only");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Public bulletin has no write actions");check(text(m.gui.screen(),"Annonce officielle159"),"Server bulletin readable by ordinary player");});
c.takeScreenshot("search159-server-readonly");
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();inventory.setItem(6,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.OAK_LOG,17));inventory.setItem(7,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,3));});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Le coffre descriptif");body(m.gui.screen()).setValue("Du bois pour le pont");press(m.gui.screen(),"sanctuary.community.task_configure");press(m.gui.screen(),"sanctuary.community.location_current");press(m.gui.screen(),"sanctuary.community.materials");edit(m.gui.screen()).setValue("minecraft:oak_log");});
c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();var slot=widgets(screen).filter(CommunityMaterialsScreen.Slot.class::isInstance).map(CommunityMaterialsScreen.Slot.class::cast).filter(w->w.getMessage().getString().startsWith("minecraft:oak_log")).findFirst().orElseThrow();slot.onPress(enter());var boxes=widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).toList();boxes.getLast().setValue("0");check(!button(m.gui.screen(),"save").active,"Invalid quantity cannot be saved");boxes.getLast().setValue("128");check(button(m.gui.screen(),"save").active,"Valid quantity restores save");});
c.takeScreenshot("cards158-materials");
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.audience.shared");press(m.gui.screen(),"sanctuary.community.reward.none");press(m.gui.screen(),"sanctuary.community.reward.items");edit(m.gui.screen()).setValue("Un repas au village");press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
c.takeScreenshot("cards158-notice-details");
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();check(inventory.getItem(6).getCount()==17&&inventory.getItem(6).is(net.minecraft.world.item.Items.OAK_LOG)&&inventory.getItem(7).getCount()==3&&inventory.getItem(7).is(net.minecraft.world.item.Items.DIAMOND),"Descriptive chest preserves real inventory");});
c.runOnClient(m->{check(text(m.gui.screen(),"Un repas au village"),"Custom reward round trip");CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);c.waitTicks(25);
c.runOnClient(m->{NotificationClient.options().pinnedAdvancements=true;press(m.gui.screen(),"sanctuary.community.follow");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(NotificationClient.tracked().stream().anyMatch(t->t.title().getString().equals("Demande 0")),"Server-backed quest appears in HUD"));
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);c.takeScreenshot("cards158-follow-hud");
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.map_show")&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.map_show"));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&!a.questPins().isEmpty(),200);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Followed location appears on atlas");check(CommunityMapMarkers.project("minecraft:the_nether",x->100,z->100,0,0,300,300).isEmpty(),"Other dimension has no pin");
var tooltip=new StringBuilder();for(var line:CommunityMapMarkers.tooltip(pin.marker())){line.accept((index,style,point)->{tooltip.appendCodePoint(point);return true;});tooltip.append(' ');}
check(tooltip.toString().contains("Voisin")&&tooltip.toString().contains("128")&&tooltip.toString().contains("Un repas")&&tooltip.toString().contains("Rendez-vous"),"Hover includes author, summary, materials and reward");
hover(m,pin.x(),pin.y());
});
c.waitTicks(3);c.takeScreenshot("search159-map-hover"+(database?"-database":"-file"));
for(int scale:List.of(3,4)) {
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();var pin=((SanctuaryAtlasScreen)m.gui.screen()).questPins().getFirst();hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-map-hover-gui"+scale);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(a.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false),"Quest marker click handled");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
world.getServer().runOnServer(server->server.getPlayerList().getPlayers().getFirst().teleportTo(20.5,65,20.5));
c.waitTicks(5);c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(20);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Pause map includes followed location");hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-pause-map-hover"+(database?"-database":"-file"));
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();map.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);c.waitTicks(25);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.unfollow"));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(CommunityMapMarkers.project("minecraft:overworld",x->100,z->100,0,0,300,300).isEmpty(),"Unfollowing removes the map marker immediately"));
c.runOnClient(m->m.gui.setScreen(null));
c.runOnClient(m->m.getConnection().sendCommand("sanctuary community admin"));c.waitTicks(5);
c.runOnClient(m->check(m.gui.screen()==null,"Non-operator cannot open administration"));
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new WorldOptionsScreen(null,m.level));});c.waitTicks(3);
c.runOnClient(m->{widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith("Allow Commands")).findFirst().orElseThrow().onPress(enter());widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().equals("Apply Changes")).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->ProgressionClient.operator,150);c.runOnClient(m->{m.gui.setScreen(null);m.getConnection().sendCommand("sanctuary community admin");});
c.waitFor(m->m.gui.screen() instanceof CommunityScreen&&has(m.gui.screen(),"sanctuary.community.publish")&&!CommunityClient.pending(),200);
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.publish");edit(m.gui.screen()).setValue("Message officiel");body(m.gui.screen()).setValue("Bienvenue !");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");CommunityClient.open(null,"bulletin","",false);});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==2&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public bulletin remains read only for operators");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions"),"Operator uses administration to edit official messages");m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->CommunityClient.overview.has("bulletins")&&!CommunityClient.pending(),200);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitTicks(20);
c.runOnClient(m->{
var date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().endsWith(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");
});
c.takeScreenshot("search159-pause-year-"+locale+"-"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("SEARCH159_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
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 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();}
}
@@ -16,6 +16,7 @@ import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
public final class Pause155ClientChecks implements FabricClientGameTest {
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
@@ -41,7 +42,7 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0));
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
} catch(Exception e) {throw new AssertionError(e);}
});
@@ -59,7 +60,8 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
var options=nativeButton(s,"menu.options");var worldOptions=nativeButton(s,"options.worldOptions.button");var quit=nativeButton(s,"menu.returnToMenu");var resume=nativeButton(s,"menu.returnToGame");
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-8,"Options and quit stacked at bottom left");
check(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getBottom()==s.height-8,"Resume below central map");
var feed=areas(s).getFirst();
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
for(var e:s.children())if(e instanceof Button b && b.getX()==8 && b.getY()<options.getY())check(b.getBottom()<options.getY(),"Navigation does not overlap options");
check(!has(s,"gui.advancements") && !has(s,"gui.stats") && !has(s,"menu.game"),"Old grid removed");
for(var e:s.children())if(e instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Widget bounds "+w.getClass().getSimpleName()+" "+w.getX()+","+w.getY()+" "+w.getWidth()+"x"+w.getHeight()+" / "+s.width+"x"+s.height);
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Screenshot selection and article round trip through actual client/server packets. */
public final class Photos157ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
try {
Files.createDirectories(folder.get());
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
}catch(Exception e){throw new AssertionError(e);}
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
press(m.gui.screen(),"sanctuary.community.photo_choose");
});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->{
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("photos157-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
c.runOnClient(m->check(widgets(m.gui.screen()).anyMatch(CommunityArticle.class::isInstance),"Published photo displayed"));
c.takeScreenshot("photos157-article"+(database?"-database":"-file"));
world.getServer().runOnServer(s->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var posts=store.list("article","",0).entries();check(posts.size()==1,"Single publication");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
}catch(Exception e){throw new AssertionError(e);}});
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("PHOTOS157_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
}
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonObject;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
import 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. */
final class CommunityArticle extends AbstractWidget implements AutoCloseable {
private final JsonObject entry;
private final int imageWidth,textX;
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;
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);
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);
}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;
g.fill(x,y,getRight(),getBottom(),0xed171d23);
if(photo!=null){photo.setPosition(x+10,y+12);photo.extractRenderState(g,mx,my,delta);}
else g.text(font,text("photo_unavailable"),x+10,y+12,0xffaaaaaa,false);
for(int i=0;i<title.size();i++){g.pose().pushMatrix();g.pose().translate(tx,top+i*15);g.pose().scale(titleScale,titleScale);g.text(font,title.get(i),0,0,0xfff4f0e8,false);g.pose().popMatrix();}
top+=title.size()*15+12;for(int i=0;i<body.size();i++)g.text(font,body.get(i),tx,top+i*11,0xffd7dce0,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
}
@@ -0,0 +1,60 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonObject;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.component.ResolvableProfile;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** A single accessible card, with a face/title row and a category-coloured frame. */
final class CommunityCard extends AbstractButton implements AutoCloseable {
private final JsonObject entry;
private final Runnable action;
private final PlayerFaceWidget face;
private final boolean compact,article;
private CommunityPhotoWidget photo;
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xff80b2d2;};}
private static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff202d37;};}
CommunityCard(int width,JsonObject entry,boolean compact,Runnable action) {
super(0,0,width,compact?44:90,Component.literal(str(entry,"title")));this.entry=entry;this.compact=compact;this.action=action;article=str(entry,"kind").equals("article");
face=new PlayerFaceWidget(compact?20:24,ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId"))));
setTooltip(Tooltip.create(getMessage().copy().append("\n").append(str(entry,"authorName")).append("\n").append(CommunityScreen.date(entry))));
if(article&&!compact) {
height=width*9/16+78;
try {
String thumbnail=str(entry,"thumbnail");
if(!thumbnail.isEmpty())photo=new CommunityPhotoWidget(width-12,width*9/16,fr.koka.sanctuary.community.CommunityPhoto.decode(Base64.getDecoder().decode(thumbnail),160,90),text("photo"),false,null);
}catch(Exception ignored){}
}
}
PlayerFaceWidget face(){return face;}
@Override public void onPress(InputWithModifiers input){action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY(),accent=article?0xffaebfae:color(str(entry,"category"));var font=Minecraft.getInstance().font;
g.fill(x,y,getRight(),getBottom(),article?0xff202720:background(str(entry,"category")));
g.fill(x,y,x+3,getBottom(),accent);
if(isHoveredOrFocused()){g.fill(x,y,getRight(),y+1,accent);g.fill(x,getBottom()-1,getRight(),getBottom(),accent);g.fill(getRight()-1,y,getRight(),getBottom(),accent);}
int top=y+6;
if(article&&!compact) {
if(photo!=null){photo.setPosition(x+6,top);photo.extractRenderState(g,mx,my,delta);}else g.text(font,text("photo_unavailable"),x+10,top+12,0xffaaaaaa,false);
top+=width*9/16+6;
}
face.setPosition(x+7,top);face.extractRenderState(g,mx,my,delta);
int tx=x+(compact?33:37),tw=width-(compact?40:44);
var lines=font.split(getMessage(),tw);
for(int i=0;i<Math.min(compact?1:2,lines.size());i++)g.text(font,lines.get(i),tx,top+(compact?6:i*10),0xfff4f0e8,false);
if(compact)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+31,accent,false);
else {
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,top+30,0xffd6d4c9,false);
g.text(font,font.plainSubstrByWidth(CommunityScreen.date(entry),width-14),x+7,top+42,0xffa2aaa4,false);
if(!article)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,top+59,accent,false);
}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
@Override public void close(){if(photo!=null){photo.close();photo=null;}}
}
@@ -14,28 +14,31 @@ public final class CommunityClient {
public static JsonObject overview = new JsonObject();
public static boolean stale = true;
public static String overviewError = "";
private static int sequence, pending, ticks;
private static int sequence, pending, ticks, followTicks;
private static Screen owner;
private static Consumer<JsonObject> callback;
private static boolean overviewRequest;
static final Map<String,Draft> DRAFTS = new HashMap<>();
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info"; }
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info", photo = ""; fr.koka.sanctuary.community.CommunityTask task=fr.koka.sanctuary.community.CommunityTask.EMPTY; }
private CommunityClient() { }
public static net.minecraft.network.chat.MutableComponent text(String key, Object... args) { return Component.translatable("sanctuary.community." + key,args); }
public static boolean available() { return ClientPlayNetworking.canSend(CommunityNetwork.Request.TYPE); }
public static boolean pending() { return pending != 0; }
public static void register() {
ClientPlayNetworking.registerGlobalReceiver(CommunityNetwork.Response.TYPE,(r,c) -> c.client().execute(() -> {
if(r.sequence()==0 && r.json().equals("{\"admin\":true}")){c.client().gui.setScreen(CommunityScreen.admin(null));return;}
if (r.sequence() != pending) return;
var result = JsonParser.parseString(r.json()).getAsJsonObject(); finish(result);
}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c) -> { pending = 0; owner = null; callback = null; overview = new JsonObject(); stale = true; overviewError = ""; DRAFTS.clear(); });
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if(++followTicks%100==0 && pending==0 && client.player!=null && (client.gui.screen()==null || client.gui.screen() instanceof SanctuaryAtlasScreen || client.gui.screen() instanceof PauseAtlasAccess) && available())request(null,"following","","","notice","info","","",0,0,r->{});
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
});
}
private static void finish(JsonObject result) {
pending = 0;
if(result.has("following")){NotificationClient.community(result.getAsJsonArray("following"));CommunityMapMarkers.accept(result.getAsJsonArray("following"));}
if (overviewRequest) {
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
else { stale = true; overviewError = str(result,"error"); }
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonArray;
import fr.koka.sanctuary.community.CommunityTask;
import java.util.*;
import java.util.function.DoubleToIntFunction;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.network.chat.Component;
/** Locations deliberately shared by followed notices; these never reveal terrain or generate chunks. */
final class CommunityMapMarkers {
record Marker(String id,String title,String author,String summary,CommunityTask task,int color) { }
record Pin(Marker marker,int x,int y) {
boolean contains(double mx,double my){return Math.abs(mx-x)<=7 && Math.abs(my-y)<=8;}
}
private static List<Marker> markers=List.of();
private CommunityMapMarkers() { }
static void clear(){markers=List.of();}
static void accept(JsonArray entries) {
var next=new ArrayList<Marker>();
for(var value:entries) {
var e=value.getAsJsonObject();
if(!CommunityClient.str(e,"status").equals("open") || !e.has("task") || e.get("task").isJsonNull())continue;
var task=new com.google.gson.Gson().fromJson(e.get("task"),CommunityTask.class);
if(task.location()!=null)next.add(new Marker(CommunityClient.str(e,"id"),CommunityClient.str(e,"title"),CommunityClient.str(e,"authorName"),CommunityClient.str(e,"summary"),task,CommunityCard.color(CommunityClient.str(e,"category"))));
}
markers=List.copyOf(next);
}
static List<Pin> project(String dimension,DoubleToIntFunction screenX,DoubleToIntFunction screenZ,int left,int top,int right,int bottom) {
var pins=new ArrayList<Pin>();
for(var marker:markers) {
var location=marker.task().location();if(!location.dimension().equals(dimension))continue;
int x=screenX.applyAsInt(location.x()+.5),y=screenZ.applyAsInt(location.z()+.5);
if(x>=left+7 && x<right-7 && y>=top+8 && y<bottom-8)pins.add(new Pin(marker,x,y));
}
return List.copyOf(pins);
}
static Pin at(List<Pin> pins,double x,double y){return pins.stream().filter(p->p.contains(x,y)).findFirst().orElse(null);}
static boolean render(GuiGraphicsExtractor g,List<Pin> pins,int mouseX,int mouseY) {
var font=Minecraft.getInstance().font;
for(var pin:pins) {
int x=pin.x(),y=pin.y();
g.fill(x-7,y-8,x+8,y+9,pin.contains(mouseX,mouseY)?0xfff4e7b4:pin.marker().color());
g.fill(x-6,y-7,x+7,y+8,0xff20251e);
g.fill(x-1,y-5,x+2,y+2,0xffffd35a);
g.fill(x-1,y+4,x+2,y+6,0xffffd35a);
}
var hovered=at(pins,mouseX,mouseY);
if(hovered==null)return false;
g.setTooltipForNextFrame(font,tooltip(hovered.marker()),mouseX,mouseY);
return true;
}
static List<net.minecraft.util.FormattedCharSequence> tooltip(Marker marker) {
var client=Minecraft.getInstance();var font=client.font;var task=marker.task();var location=task.location();
int width=Math.min(230,client.getWindow().getGuiScaledWidth()-28);
var lines=new ArrayList<net.minecraft.util.FormattedCharSequence>();
lines.addAll(font.split(Component.literal(marker.title()).withStyle(net.minecraft.ChatFormatting.BOLD),width).stream().limit(2).toList());
lines.add(Component.literal(font.plainSubstrByWidth(marker.author(),width)).getVisualOrderText());
lines.addAll(font.split(Component.literal(marker.summary()),width).stream().limit(3).toList());
lines.addAll(font.split(Component.literal("X "+location.x()+" Y "+location.y()+" Z "+location.z()),width));
if(!task.materials().isEmpty()) {
lines.add(CommunityClient.text("materials").getVisualOrderText());
itemLines(lines,task.materials(),width,2);
}
lines.addAll(font.split(CommunityClient.text("reward."+task.reward()),width));
if(task.reward().equals("items"))itemLines(lines,task.rewards(),width,2);
if(task.reward().equals("custom"))lines.addAll(font.split(Component.literal(task.customReward()),width).stream().limit(2).toList());
lines.addAll(font.split(CommunityClient.text("audience."+task.audience()),width));
int limit=Math.max(5,(client.getWindow().getGuiScaledHeight()-34)/font.lineHeight-2);
if(lines.size()>limit){lines=new ArrayList<>(lines.subList(0,limit-1));lines.add(Component.literal("…").getVisualOrderText());}
lines.addAll(font.split(CommunityClient.text("map_followed"),width));
return List.copyOf(lines);
}
private static void itemLines(List<net.minecraft.util.FormattedCharSequence> lines,List<CommunityTask.Material> items,int width,int limit) {
var font=Minecraft.getInstance().font;
for(var item:items.stream().limit(limit).toList()) {
var name=net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item()))
.map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));
lines.addAll(font.split(Component.literal(item.count()+" × ").append(name),width));
}
if(items.size()>limit)lines.addAll(font.split(CommunityClient.text("map_more_materials",items.size()-limit),width));
}
}
@@ -0,0 +1,90 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityTask.Material;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import static fr.koka.sanctuary.client.CommunityClient.text;
/** A virtual chest: selections are descriptors and never touch a player inventory. */
final class CommunityMaterialsScreen extends BlocodexFrame {
private final List<Material> selected;
private final Consumer<List<Material>> save;
private final Map<String,Slot> selectedSlots=new HashMap<>();
private String search="",chosen="";
private boolean searchChanged,quantityValid=true;
private Button saveButton;
private LinearLayout results;
private ScrollableLayout scroll;
CommunityMaterialsScreen(Screen parent,List<Material> items,Consumer<List<Material>> save){super(parent);selected=new ArrayList<>(items);this.save=save;}
@Override protected void init(){
quantityValid=true;selectedSlots.clear();initFrame("history",text("materials"));
int w=Math.min(400,contentWidth-24),x=(width-w)/2;
var layout=LinearLayout.vertical().spacing(5);
var query=layout.addChild(new EditBox(font,w,20,text("material_search")));query.setValue(search);
query.setResponder(value->{search=value;searchChanged=true;});
if(searchChanged){setInitialFocus(query);query.moveCursorToEnd(false);searchChanged=false;}
results=LinearLayout.vertical().spacing(2);
scroll=new ScrollableLayout(minecraft,results,Math.max(40,Math.min(100,(contentBottom-contentTop)/3)),ScrollableLayout.ReserveStrategy.RIGHT);
layout.addChild(scroll);refreshResults(w);
layout.addChild(new StringWidget(text("selected_materials",selected.size(),27),font));
for(int row=0;row<3;row++){
var slots=layout.addChild(LinearLayout.horizontal().spacing(2));
for(int col=0;col<9;col++){
int index=row*9+col;
Material item=index<selected.size()?selected.get(index):null;
var slot=slots.addChild(new Slot(item==null?"minecraft:air":item.item(),item==null?0:item.count(),()->{if(item!=null){chosen=item.item();rebuildWidgets();}}));if(item!=null)selectedSlots.put(item.item(),slot);
}
}
if(!chosen.isEmpty()){
var material=selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElse(null);
if(material!=null){
var row=layout.addChild(LinearLayout.horizontal().spacing(4));
row.addChild(new StringWidget(text("quantity"),font));
var count=row.addChild(new EditBox(font,64,20,text("quantity")));count.setMaxLength(6);count.setValue(""+material.count());
count.setTooltip(Tooltip.create(Component.literal("1–999999")));count.setResponder(value->{quantityValid=value.matches("[0-9]{1,6}")&&Integer.parseInt(value)>0;if(saveButton!=null)saveButton.active=quantityValid;if(quantityValid){int index=selected.indexOf(selected.stream().filter(i->i.item().equals(chosen)).findFirst().orElseThrow());selected.set(index,new Material(chosen,Integer.parseInt(value)));var slot=selectedSlots.get(chosen);slot.count=Integer.parseInt(value);slot.setMessage(Component.literal(chosen+" × "+value));slot.setTooltip(Tooltip.create(slot.stack.isEmpty()?slot.getMessage():slot.stack.getHoverName().copy().append(" × "+value)));}});
row.addChild(Button.builder(text("remove_material"),b->{selected.removeIf(i->i.item().equals(chosen));chosen="";rebuildWidgets();}).width(110).build());
}
}
saveButton=layout.addChild(Button.builder(text("save"),b->{if(quantityValid){save.accept(List.copyOf(selected));onClose();}}).width(w).build());saveButton.active=quantityValid;
var outer=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);outer.setPosition(x,contentTop);outer.arrangeElements();outer.visitWidgets(this::addRenderableWidget);
}
@Override public void tick(){if(searchChanged)rebuildWidgets();}
private void refreshResults(int w){
if(results==null)return;
String query=search.toLowerCase(Locale.ROOT);int count=0;LinearLayout row=null;
for(var item:BuiltInRegistries.ITEM){
String id=BuiltInRegistries.ITEM.getKey(item).toString();
if(id.length()>100||id.equals("minecraft:air")||!(id.contains(query)||item.getDefaultInstance().getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)))continue;
if(count%9==0)row=results.addChild(LinearLayout.horizontal().spacing(2));
row.addChild(new Slot(id,1,()->{if(selected.stream().noneMatch(i->i.item().equals(id))&&selected.size()<27)selected.add(new Material(id,1));chosen=id;rebuildWidgets();}));
if(++count>=180)break;
}
if(count==0)results.addChild(new StringWidget(text("empty"),font));
scroll.arrangeElements();
}
static void showItems(LinearLayout layout,int width,List<Material> items){
int columns=Math.max(1,Math.min(9,width/24));LinearLayout row=null;int index=0;
for(var item:items){if(index++%columns==0)row=layout.addChild(LinearLayout.horizontal().spacing(2));row.addChild(new Slot(item.item(),item.count(),null));}
}
static final class Slot extends AbstractButton {
private final ItemStack stack;private int count;private final Runnable action;
Slot(String id,int count,Runnable action){super(0,0,22,22,Component.literal(id+" × "+count));this.count=count;this.action=action;stack=BuiltInRegistries.ITEM.getOptional(Identifier.parse(id)).map(i->i.getDefaultInstance()).orElse(ItemStack.EMPTY);setTooltip(Tooltip.create(stack.isEmpty()?getMessage():stack.getHoverName().copy().append(" × "+count)));}
@Override public void onPress(InputWithModifiers input){if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta){
var font=Minecraft.getInstance().font;int x=getX(),y=getY();g.fill(x,y,x+22,y+22,isHoveredOrFocused()?0xff536957:0xff20242b);
if(!stack.isEmpty()){g.item(stack,x+3,y+2);if(count>1){String label=count>999?"999+":""+count;g.text(font,label,x+22-font.width(label),y+14,0xffffffff,true);}}
}
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out){defaultButtonNarrationText(out);}
}
@Override public boolean isPauseScreen(){return false;}
}
@@ -31,9 +31,7 @@ public final class CommunityPausePanels implements AutoCloseable {
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
add.accept(date);add.accept(clock);
int headerSide=Math.max(84,screen.width/5);
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->{
var list=items("bulletins");open(screen,"bulletin",list.isEmpty()?"":str(list.get(0).getAsJsonObject(),"id"),false);
});
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->open(screen,"bulletin","",false));
add.accept(bulletin);
String[] sections={"blocks","history","progress","identity"};
int optionsTop=screen.height-76;
@@ -47,7 +45,7 @@ public final class CommunityPausePanels implements AutoCloseable {
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+4*step,left,navHeight).build());
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
place(nativeButtons,add,"menu.returnToGame",(screen.width-Math.min(180,centerWidth))/2,screen.height-28,Math.min(180,centerWidth));
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
int feedHeight=(bottom-top-gap)/2;
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
@@ -66,7 +64,7 @@ public final class CommunityPausePanels implements AutoCloseable {
private void updateHeader() {
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
var now=ZonedDateTime.now();
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM",locale).format(now)));
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()));
var list=items("bulletins");
@@ -74,6 +72,9 @@ public final class CommunityPausePanels implements AutoCloseable {
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);
bulletin.active=available();
}
public void tick() {
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.awt.image.BufferedImage;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
/** A locally owned texture; never installed under a user-controlled resource identifier. */
final class CommunityPhotoWidget extends AbstractButton implements AutoCloseable {
private final Identifier texture=Identifier.fromNamespaceAndPath("sanctuary","community/"+UUID.randomUUID());
private final int imageWidth,imageHeight;
private final Runnable action;
private final boolean caption;
private boolean closed;
CommunityPhotoWidget(int width,int height,BufferedImage image,Component label,boolean caption,Runnable action) {
super(0,0,width,height,label);this.action=action;this.caption=caption;
imageWidth=image.getWidth();imageHeight=image.getHeight();
var nativeImage=new NativeImage(imageWidth,imageHeight,false);
for(int y=0;y<imageHeight;y++)for(int x=0;x<imageWidth;x++)nativeImage.setPixel(x,y,0xff000000|image.getRGB(x,y));
Minecraft.getInstance().getTextureManager().register(texture,new DynamicTexture(()->"Gazette photo",nativeImage));
active=action!=null;
}
@Override public void onPress(InputWithModifiers input) {if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(closed)return;
int x=getX(),y=getY(),available=height-(caption?18:0);
g.fill(x,y,x+width,y+height,isHoveredOrFocused()?0xff536957:0xff1b2028);
double scale=Math.min((double)(width-4)/imageWidth,(double)(available-4)/imageHeight);
int w=(int)(imageWidth*scale),h=(int)(imageHeight*scale);
g.blit(RenderPipelines.GUI_TEXTURED,texture,x+(width-w)/2,y+(available-h)/2,0,0,w,h,imageWidth,imageHeight,imageWidth,imageHeight);
if(caption)g.text(Minecraft.getInstance().font,Minecraft.getInstance().font.plainSubstrByWidth(getMessage().getString(),width-10),x+5,y+height-13,0xffeeeeee,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
@Override public void close() {if(!closed){closed=true;Minecraft.getInstance().getTextureManager().release(texture);}}
}
@@ -9,23 +9,52 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Native text-only pages: user content is always a literal Minecraft component. */
/** Native community pages: user content is always a literal Minecraft component. */
public final class CommunityScreen extends BlocodexFrame {
private final String kind;
private final boolean admin;
private String search="";
private int searchAfter;
private boolean rebuildSearch;
private EditBox searchBox;
private String id, editingId = "", message = "", expandedId = "";
private int page, revision;
private boolean composing, needsLoad = true, moderator;
private JsonObject data = new JsonObject();
private Draft draft;
private int ticks;
private final java.util.List<CommunityPhotoWidget> photos=new java.util.ArrayList<>();
private final java.util.List<AutoCloseable> cards=new java.util.ArrayList<>();
private final java.util.List<JsonObject> entries=new java.util.ArrayList<>();
private boolean more,taskSent;
private ScrollableLayout contentScroll;
private double restoreScroll;
private boolean publishing;
private int photoPart, sendAfter;
private String outgoingPhoto="";
private Runnable commit;
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.clear();cards.forEach(c->{try{c.close();}catch(Exception ignored){}});cards.clear();}
@Override public void removed() {closePhotos();publishing=false;commit=null;}
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
super(parent); this.kind = kind; this.id = id; this.composing = compose;
this(parent,kind,id,compose,false);
}
static CommunityScreen admin(Screen parent){return new CommunityScreen(parent,"bulletin","",false,true);}
private CommunityScreen(Screen parent,String kind,String id,boolean compose,boolean admin) {
super(parent); this.kind = kind; this.admin=admin; this.id = id; this.composing = compose && (!kind.equals("bulletin") || admin);
moderator = flag(overview,"moderator");
if (compose) useDraft("new:" + kind);
}
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
@Override protected void rebuildWidgets() {
boolean restoreFocus=searchBox!=null && searchBox.isFocused() && !composing && id.isEmpty();
int cursor=restoreFocus?searchBox.getCursorPosition():0;
super.rebuildWidgets();
if(restoreFocus && searchBox!=null){setInitialFocus(searchBox);searchBox.moveCursorTo(cursor,false);}
}
@Override protected void init() {
initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
searchBox=null;
closePhotos();initFrame("history",text(kind)); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
var layout = LinearLayout.vertical().spacing(8);
if (!message.isEmpty()) line(layout,w,text(message));
if (!available()) line(layout,w,text("unavailable"));
@@ -34,32 +63,42 @@ public final class CommunityScreen extends BlocodexFrame {
else listing(layout,w);
var scroll = new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop); scroll.arrangeElements(); scroll.visitWidgets(this::addRenderableWidget);
contentScroll=scroll;if(restoreScroll>0){scroll.visitChildren(child->{if(child instanceof net.minecraft.client.gui.components.AbstractScrollArea area)area.setScrollAmount(restoreScroll);});restoreScroll=0;}
}
private void listing(LinearLayout layout,int w) {
var tabs = layout.addChild(LinearLayout.horizontal().spacing(4));
for (String target : new String[]{"article","notice","bulletin"}) tabs.addChild(Button.builder(text(target), b -> open(parent,target,"",false)).width((w-8)/3).build());
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(4));
if (!kind.equals("bulletin") || moderator) button(toolbar,(w-8)/3,"publish",() -> { composing = true; useDraft("new:"+kind); rebuildWidgets(); });
button(toolbar,(w-8)/3,"refresh",() -> needsLoad = true);
button(toolbar,(w-8)/3,"link",() -> minecraft.gui.setScreen(new LinkScreen(this)));
if (!data.has("page")) { line(layout,w,text("loading")); return; }
var listing = data.getAsJsonObject("page");
if (listing.getAsJsonArray("entries").isEmpty()) line(layout,w,text("empty"));
for (var item : listing.getAsJsonArray("entries")) {
var entry = item.getAsJsonObject();
layout.addChild(Button.builder(Component.literal((flag(entry,"pinned") ? "★ " : "") + str(entry,"title")), b -> { id=str(entry,"id"); page=0; needsLoad=true; }).width(w).build());
author(layout,w,entry,false);
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
var tabs=layout.addChild(LinearLayout.horizontal().spacing(6));
if(!admin)for(String target:new String[]{"article","notice","bulletin"})tabs.addChild(Button.builder(text(target.equals("bulletin")?"server_tab":target.equals("notice")?"notice_tab":target),b->open(parent,target,"",false)).width((w-12)/3).build());
searchBox=layout.addChild(new EditBox(font,w,20,text("search")));
searchBox.setMaxLength(120);searchBox.setHint(text("search"));searchBox.setValue(search);
searchBox.setResponder(value->{search=value;page=0;more=false;entries.clear();restoreScroll=0;needsLoad=true;searchAfter=ticks+6;rebuildSearch=true;});
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(6));
if(!kind.equals("bulletin") || admin)button(toolbar,(w-6)/2,"publish",()->{composing=true;useDraft("new:"+kind);rebuildWidgets();});
button(toolbar,(w-6)/2,"refresh",()->{page=0;needsLoad=true;});
if(entries.isEmpty())line(layout,w,text(needsLoad?"loading":"empty"));
int cardWidth=(w-8)/2;
for(int i=0;i<entries.size();i+=2) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+2,entries.size());j++) {
var entry=entries.get(j);
var card=new CommunityCard(cardWidth,entry,false,()->{id=str(entry,"id");page=0;data=new JsonObject();needsLoad=true;});
cards.add(card);row.addChild(card);
}
}
pages(layout,w,listing);
if(more)button(layout,w,"more",()->{restoreScroll=contentScroll.getScrollAmount();page++;needsLoad=true;});
}
private void detail(LinearLayout layout,int w,JsonObject thread) {
var entry = thread.getAsJsonObject("entry");
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(5));
button(toolbar,90,"back",() -> { id=""; page=0; data=new JsonObject(); needsLoad=true; rebuildWidgets(); });
button(toolbar,90,"refresh",() -> needsLoad=true);
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));
message(layout,w,entry);
if(kind.equals("article")) {
var article=new CommunityArticle(w,entry);cards.add(article);layout.addChild(article);
articleAuthor(layout,w,entry);
if(expandedId.equals(id))controls(layout,w,entry);
} else {
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));message(layout,w,entry);
if(kind.equals("notice"))taskDetails(layout,w,entry);
}
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
if (!kind.equals("bulletin")) {
line(layout,w,text("replies"));
@@ -79,6 +118,14 @@ public final class CommunityScreen extends BlocodexFrame {
}
}
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
private void articleAuthor(LinearLayout layout,int w,JsonObject entry) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
String when=date(entry),name=font.plainSubstrByWidth(str(entry,"authorName"),Math.max(20,w-76-font.width(when)));
var metadata=Component.literal(name+" · "+when);
row.addChild(new StringWidget(Math.min(w-58,font.width(metadata)),24,metadata,font));
if(own(entry)||moderator)row.addChild(Button.builder(text("actions"),b->{expandedId=expandedId.equals(id)?"":id;rebuildWidgets();}).size(20,24).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void author(LinearLayout layout,int w,JsonObject entry,boolean actions) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
row.addChild(new PlayerFaceWidget(24,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(java.util.UUID.fromString(str(entry,"authorId")))));
@@ -90,16 +137,17 @@ public final class CommunityScreen extends BlocodexFrame {
}).size(20,18).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void message(LinearLayout layout,int w,JsonObject entry) {
author(layout,w,entry,true);
author(layout,w,entry,!kind.equals("bulletin") || admin);
layout.addChild(new CommunityBubble(w,str(entry,"body"),own(entry)));
if(expandedId.equals(str(entry,"id"))) controls(layout,w,entry);
}
private void controls(LinearLayout layout,int w,JsonObject entry) {
if(kind.equals("bulletin") && !admin)return;
boolean own = own(entry);
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
if (own) button(row,size,"edit",() -> {
editingId=str(entry,"id"); revision=number(entry,"revision"); composing=true; useDraft("edit:"+editingId);
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); }
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); draft.photo=str(entry,"photo"); draft.task=readTask(entry); }
rebuildWidgets();
});
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
@@ -114,26 +162,75 @@ public final class CommunityScreen extends BlocodexFrame {
if (!reply) {
line(layout,w,text("title_label")); var titleBox=layout.addChild(new EditBox(font,w,20,text("title_label")));
titleBox.setMaxLength(120); titleBox.setValue(draft.title); titleBox.setResponder(value -> draft.title=value);
if (kind.equals("notice") && editingId.isEmpty()) button(layout,w,"category."+draft.category,() -> {
if (kind.equals("notice")) button(layout,w,"category."+draft.category,() -> {
String[] categories={"info","work","need","event"}; int i=java.util.Arrays.asList(categories).indexOf(draft.category);
draft.category=categories[(i+1)%categories.length]; rebuildWidgets();
});
}
if(!reply && kind.equals("article")) {
photo(layout,w,draft.photo);
button(layout,w,draft.photo.isEmpty()?"photo_choose":"photo_change",()->minecraft.gui.setScreen(new CommunityScreenshotsScreen(this,value->draft.photo=value)));
if(draft.photo.isEmpty())line(layout,w,text("photo_required"));
}
if(!reply && kind.equals("notice")) {
button(layout,w,"task_configure",()->minecraft.gui.setScreen(new CommunityTaskScreen(this,draft.task,value->draft.task=value)));
line(layout,w,text("task_summary",draft.task.materials().size(),text("audience."+draft.task.audience())));
}
line(layout,w,text("body_label"));
var bodyBox=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("body_hint")).build(font,w,Math.max(60,Math.min(150,height/3)),text("body_label")));
bodyBox.setCharacterLimit(reply?1000:4000); bodyBox.setValue(draft.body); bodyBox.setValueListener(value -> draft.body=value);
button(layout,110,editingId.isEmpty()?"publish":"save",() -> {
var publish=layout.addChild(Button.builder(text(publishing?"photo_uploading":editingId.isEmpty()?"publish":"save"),b -> {
if(pending() || publishing)return;
String action=editingId.isEmpty()?"create":"edit";
request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
commit=()->request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
reply?"":draft.title,draft.body,revision,0,result -> {
publishing=false;commit=null;
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
String saved=str(result.getAsJsonObject("result"),"id");
DRAFTS.values().removeIf(value -> value==draft); composing=false; editingId="";
if (!reply) id=saved; page=0; needsLoad=true; message="saved"; stale=true; rebuildWidgets();
});
});
if(!reply && (kind.equals("article")||kind.equals("notice"))) {outgoingPhoto=kind.equals("article")?draft.photo:"";photoPart=0;taskSent=!kind.equals("notice");publishing=true;sendAfter=ticks+4;rebuildWidgets();}
else {var actionToRun=commit;commit=null;actionToRun.run();}
}).width(150).build());
publish.active=!publishing && (reply || !kind.equals("article") || !draft.photo.isEmpty());
button(layout,Math.min(w,220),"back_draft",() -> { composing=false; editingId=""; needsLoad=true; rebuildWidgets(); });
}
private void photo(LinearLayout layout,int w,String value) {
if(value.isEmpty())return;
try {
var image=fr.koka.sanctuary.community.CommunityPhoto.decode(java.util.Base64.getDecoder().decode(value),960,540);
var widget=new CommunityPhotoWidget(Math.min(w,160),Math.max(24,Math.min(72,160*image.getHeight()/image.getWidth())),image,text("photo"),false,null);
photos.add(widget);layout.addChild(widget);
} catch(Exception e) {line(layout,w,text("photo_invalid"));}
}
private static fr.koka.sanctuary.community.CommunityTask readTask(JsonObject entry) {
return entry.has("task")&&!entry.get("task").isJsonNull()?new Gson().fromJson(entry.get("task"),fr.koka.sanctuary.community.CommunityTask.class):fr.koka.sanctuary.community.CommunityTask.EMPTY;
}
private void taskDetails(LinearLayout layout,int w,JsonObject entry) {
var task=readTask(entry);
if(task.location()!=null) {
line(layout,w,text("location_value",task.location().dimension(),task.location().x(),task.location().y(),task.location().z()));
var map=layout.addChild(Button.builder(text("map_show"),b->minecraft.gui.setScreen(new SanctuaryAtlasScreen(this,task.location()))).width(Math.min(w,220)).build());
map.active=minecraft.level!=null && task.location().dimension().equals(minecraft.level.dimension().identifier().toString());
if(!map.active)map.setTooltip(Tooltip.create(text("map_other_dimension")));
}
if(!task.materials().isEmpty()) {line(layout,w,text("materials"));CommunityMaterialsScreen.showItems(layout,w,task.materials());}
line(layout,w,text("audience."+task.audience()));line(layout,w,text("reward."+task.reward()));
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
if(task.reward().equals("custom"))line(layout,w,Component.literal(task.customReward()));
line(layout,w,text("exchange_help"));
int count=data.has("members")?data.getAsJsonArray("members").size():0;
line(layout,w,text("followers",count));
if(data.has("members"))for(var member:data.getAsJsonArray("members"))line(layout,w,Component.literal("• "+str(member.getAsJsonObject(),"playerName")));
boolean subscribed=flag(data,"subscribed");
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
rebuildWidgets();
})).width(w).build());
follow.active=subscribed || (!own(entry)&&str(entry,"status").equals("open")&&(!task.audience().equals("single")||count==0));
}
private void mutate(String action,JsonObject entry) {
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
message=result.has("error")?str(result,"error"):"saved";
@@ -151,28 +248,41 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void line(LinearLayout layout,int w,Component value) { layout.addChild(new MultiLineTextWidget(value,font).setMaxWidth(w)); }
private void button(LinearLayout layout,int w,String key,Runnable action) {
layout.addChild(Button.builder(text(key),b -> { if (!pending()) action.run(); }).width(w).build());
layout.addChild(Button.builder(text(key),b -> { if (!publishing) action.run(); }).width(w).build());
}
private static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
@Override public void tick() {
super.tick(); ticks++;
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
if(rebuildSearch){rebuildSearch=false;rebuildWidgets();}
if(publishing && !pending() && ticks>=sendAfter) {
if(!taskSent) {
request(this,"task",editingId.isEmpty()?draft.operation:editingId,"","notice","info","",new GsonBuilder().disableHtmlEscaping().create().toJson(draft.task),0,0,result->{
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
else {taskSent=true;sendAfter=ticks+4;}
});return;
}
int parts=(outgoingPhoto.length()+7999)/8000;
if(photoPart<parts) {
int part=photoPart;
request(this,"photo",editingId.isEmpty()?draft.operation:editingId,"","article","info","",outgoingPhoto.substring(part*8000,Math.min(outgoingPhoto.length(),(part+1)*8000)),parts,part,result->{
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
else {photoPart++;sendAfter=ticks+4;}
});
} else if(commit!=null) {var actionToRun=commit;commit=null;actionToRun.run();}
}
String requestedSearch=search,requestedId=id;int requestedPage=page;
if (!composing && needsLoad && ticks>=searchAfter && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","",id.isEmpty()?search:"",0,page,result -> {
if(!search.equals(requestedSearch) || !id.equals(requestedId) || page!=requestedPage || composing)return;
if (result.has("error")) message=str(result,"error");
else { data=result; moderator=flag(result,"moderator"); }
else {data=result;moderator=flag(result,"moderator");
if(id.isEmpty()&&result.has("page")) {
if(page==0)entries.clear();
for(var entry:result.getAsJsonObject("page").getAsJsonArray("entries"))if(entries.stream().noneMatch(e->str(e,"id").equals(str(entry.getAsJsonObject(),"id"))))entries.add(entry.getAsJsonObject());
more=flag(result.getAsJsonObject("page"),"more");
}
}
rebuildWidgets();
})) needsLoad=false;
}
@Override public boolean isPauseScreen() { return false; }
private static final class LinkScreen extends BlocodexFrame {
String code="", message="link_help";
LinkScreen(Screen parent) { super(parent); }
@Override protected void init() {
initFrame("history",text("link")); int w=contentWidth-28; var layout=LinearLayout.vertical().spacing(8);
layout.addChild(new MultiLineTextWidget(text(message),font).setMaxWidth(w));
var box=layout.addChild(new EditBox(font,w,20,text("code"))); box.setMaxLength(32); box.setValue(code); box.setResponder(value -> code=value);
layout.addChild(Button.builder(text("link"),b -> request(this,"link","","","article","info","",code,0,0,r -> { message=r.has("error")?str(r,"error"):"linked"; rebuildWidgets(); })).width(w).build());
layout.setPosition(contentX+8,contentTop); layout.arrangeElements(); layout.visitWidgets(this::addRenderableWidget);
}
@Override public boolean isPauseScreen() { return false; }
}
}
@@ -0,0 +1,110 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityPhoto;
import java.awt.image.BufferedImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.text;
/** Read-only screenshot library, paged and scrollable; no disk access or source-image decoding on the render thread. */
public final class CommunityScreenshotsScreen extends BlocodexFrame {
private static final int PAGE_SIZE=24;
private static final ExecutorService LOADER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary screenshots");t.setDaemon(true);return t;});
private record Shot(String name,String photo,BufferedImage thumbnail) { }
private record Library(List<Path> paths,List<Shot> shots,String error) { }
private final Consumer<String> assign;
private final List<CommunityPhotoWidget> textures=new ArrayList<>();
private List<Path> paths=List.of();
private List<Shot> shots=List.of();
private CompletableFuture<Library> loading;
private int page;
private boolean loaded;
private String error="";
private Shot selected;
public CommunityScreenshotsScreen(Screen parent,Consumer<String> assign) {super(parent);this.assign=assign;}
private void load(boolean refresh) {
loaded=true;
Path folder=minecraft.gameDirectory.toPath().resolve("screenshots");
List<Path> known=paths;int offset=page*PAGE_SIZE;
loading=CompletableFuture.supplyAsync(()->{
try {
List<Path> all=known;
if(refresh) {
if(!Files.isDirectory(folder))return new Library(List.of(),List.of(),"");
try(var files=Files.list(folder)) {
all=files.filter(p->p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")&&Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.comparing((Path p)->p.getFileName().toString()).reversed()).limit(4096).toList();
}
}
var found=new ArrayList<Shot>();
for(Path file:all.stream().skip(offset).limit(PAGE_SIZE).toList()) {
try {
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS) || Files.size(file)>32*1024*1024)throw new java.io.IOException("Screenshot size");
byte[] bytes;
try(var in=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)) {bytes=in.readNBytes(32*1024*1024+1);}
if(bytes.length>32*1024*1024)throw new java.io.IOException("Screenshot size");
var source=CommunityPhoto.decode(bytes,8192,8192);
String photo=CommunityPhoto.encode(source,CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
String thumb=CommunityPhoto.encode(source,240,135);
found.add(new Shot(file.getFileName().toString(),photo,CommunityPhoto.decode(Base64.getDecoder().decode(thumb),240,135)));
} catch(Exception e) {found.add(new Shot(file.getFileName().toString(),"",null));}
}
return new Library(all,found,"");
} catch(Exception e) {return new Library(List.of(),List.of(),"screenshots_error");}
},LOADER);
}
@Override protected void init() {
closeTextures();initFrame("history",text("screenshots"));
contentWidth=Math.max(240,width-24);contentX=(width-contentWidth)/2;
if(!loaded)load(true);
if(selected!=null) {preview();return;}
int w=contentWidth-24;
var controls=LinearLayout.horizontal().spacing(4);
controls.addChild(Button.builder(text("previous"),b->{page--;load(false);rebuildWidgets();}).width(72).build()).active=page>0 && loading==null;
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
controls.setPosition(contentX+8,contentTop+Math.max(24,contentBottom-contentTop-28)+8);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
var layout=LinearLayout.vertical().spacing(8);
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
else {
int columns=Math.max(2,w/112),cell=(w-(columns-1)*8)/columns;
for(int i=0;i<shots.size();i+=columns) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
Shot shot=shots.get(j);
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
}
}
}
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void preview() {
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
int left=(width-w)/2;
try {
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
textures.add(widget);widget.setPosition(left,contentTop);addRenderableWidget(widget);
} catch(Exception e) {error="photo_invalid";selected=null;return;}
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(left,contentTop+h+8,(w-6)/2,20).build());
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(left+(w+6)/2,contentTop+h+8,(w-6)/2,20).build());
}
@Override public void tick() {
if(loading!=null && loading.isDone()) {
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
}
}
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
@Override public void removed() {closeTextures();}
@Override public boolean isPauseScreen() {return false;}
}
@@ -0,0 +1,38 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityTask;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import static fr.koka.sanctuary.client.CommunityClient.text;
final class CommunityTaskScreen extends BlocodexFrame {
private final Consumer<CommunityTask> save;
private CommunityTask.Location location;
private List<CommunityTask.Material> materials,rewards;
private String audience,reward,custom,error="";
CommunityTaskScreen(Screen parent,CommunityTask task,Consumer<CommunityTask> save){super(parent);this.save=save;location=task.location();materials=task.materials();rewards=task.rewards();audience=task.audience();reward=task.reward();custom=task.customReward();}
@Override protected void init(){
initFrame("history",text("task_configure"));int w=Math.min(400,contentWidth-24);
var layout=LinearLayout.vertical().spacing(8);
if(!error.isEmpty())layout.addChild(new MultiLineTextWidget(text(error),font).setMaxWidth(w));
layout.addChild(new MultiLineTextWidget(text("exchange_help"),font).setMaxWidth(w));
button(layout,w,"location_current",()->{if(minecraft.player!=null){var p=minecraft.player.blockPosition();location=new CommunityTask.Location(minecraft.player.level().dimension().identifier().toString(),p.getX(),p.getY(),p.getZ());rebuildWidgets();}});
if(location!=null){
layout.addChild(new MultiLineTextWidget(text("location_value",location.dimension(),location.x(),location.y(),location.z()),font).setMaxWidth(w));
button(layout,w,"location_remove",()->{location=null;rebuildWidgets();});
}
button(layout,w,"materials",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,materials,value->materials=value)));
CommunityMaterialsScreen.showItems(layout,w,materials);
button(layout,w,"audience."+audience,()->{audience=audience.equals("shared")?"single":"shared";rebuildWidgets();});
button(layout,w,"reward."+reward,()->{reward=switch(reward){case "none"->"items";case "items"->"custom";default->"none";};rebuildWidgets();});
if(reward.equals("items")){button(layout,w,"rewards_choose",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,rewards,value->rewards=value)));CommunityMaterialsScreen.showItems(layout,w,rewards);}
if(reward.equals("custom")){var box=layout.addChild(new EditBox(font,w,20,text("reward.custom")));box.setMaxLength(240);box.setValue(custom);box.setResponder(value->custom=value);}
button(layout,w,"save",()->{try{save.accept(new CommunityTask(location,materials,audience,reward,reward.equals("items")?rewards:List.of(),reward.equals("custom")?custom:""));onClose();}catch(fr.koka.sanctuary.community.Community.Refused e){error="task_invalid";rebuildWidgets();}});
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition((width-w)/2,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void button(LinearLayout layout,int w,String key,Runnable action){layout.addChild(Button.builder(text(key),b->action.run()).width(w).build());}
@Override public boolean isPauseScreen(){return false;}
}
@@ -30,6 +30,7 @@ public final class NotificationClient {
private static double animatedBlocks,animatedItems,animatedRecipes;
private static boolean hasRoom=true;
private static String trackingScope;
private static List<Track> communityTracks=List.of();
private NotificationClient(){}
public static NotificationOptions options(){if(config==null)config=NotificationOptions.load();return config;}
public static Component label(String key,Object...args){return Component.translatable("sanctuary.notifications."+key,args);}
@@ -40,10 +41,24 @@ public final class NotificationClient {
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
}
public static void reset(){FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void reset(){CommunityMapMarkers.clear();communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void clearFeed(){FEED.clear();}
public static List<NotificationFeed.Entry<Message>> messages(){return FEED.entries();}
public static List<Track> tracked(){return OBJECTIVES.shown(options().pinnedAdvancements,options().advancements);}
public static List<Track> tracked(){var tracks=new ArrayList<>(OBJECTIVES.shown(options().pinnedAdvancements,options().advancements));if(options().pinnedAdvancements)tracks.addAll(communityTracks);return tracks.stream().distinct().limit(6).toList();}
public static void communitySubscribed(Component title){push("community:follow",2,CommunityClient.text("followed",title),0x80c1a4);}
public static void community(com.google.gson.JsonArray entries){
var tracks=new ArrayList<Track>();var snapshots=new ArrayList<ObjectiveTracker.Objective<Track>>();
for(var value:entries){
var e=value.getAsJsonObject();var task=new com.google.gson.Gson().fromJson(e.get("task"),fr.koka.sanctuary.community.CommunityTask.class);
boolean closed=CommunityClient.str(e,"status").equals("closed");
Component detail=CommunityClient.text(closed?"closed":"open");
if(task.location()!=null)detail=detail.copy().append(" · "+task.location().x()+", "+task.location().y()+", "+task.location().z());
if(!task.materials().isEmpty()) {var item=task.materials().getFirst();var name=BuiltInRegistries.ITEM.getOptional(Identifier.parse(item.item())).map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));detail=detail.copy().append(" · "+item.count()+" × ").append(name);}
var track=new Track(Component.literal(CommunityClient.str(e,"title")),detail,closed?1:0,CommunityCard.color(CommunityClient.str(e,"category"))&0xffffff);
snapshots.add(new ObjectiveTracker.Objective<>(CommunityClient.str(e,"id"),track,closed?1:0,closed));if(!closed&&tracks.size()<3)tracks.add(track);
}
objectives("community",snapshots);communityTracks=List.copyOf(tracks);
}
public static boolean isPinned(Identifier id){return OBJECTIVES.isPinned(new ObjectiveTracker.Key("advancement",id.toString()));}
/** Presentation entry point for validated server snapshots, on the Minecraft client thread.
* A quest adapter can use source "quest" and its own IDs without a second HUD. */
@@ -111,7 +126,7 @@ public final class NotificationClient {
long now=System.nanoTime()/1_000_000L,elapsed=lastClock==0?0:now-lastClock;lastClock=now;
if(m.player==null||m.level==null)return;
if(m.player!=inhabitant||!m.player.isAlive()){
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();communityTracks=List.of();
}
if(!m.player.isAlive())return;
ensureTracking(m);
@@ -69,6 +69,16 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
if(--refresh<=0)request();
}
public int knownPixels() {return pixels;}
private double zoom(){return Math.max(.75,Math.max(width,height)/992.);}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,wx->getX()+width/2+(int)Math.round((wx-centerX)*zoom()),
wz->getY()+height/2+(int)Math.round((wz-centerZ)*zoom()),getX(),getY()+20,getRight(),getBottom()-20);
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick) {
var pin=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT && pin!=null){CommunityClient.open(owner,"notice",pin.marker().id(),false);return true;}
return super.mouseClicked(event,doubleClick);
}
@Override public void onPress(InputWithModifiers input) {ProgressionClient.open(owner,"history");}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(texture==null)return;
@@ -77,7 +87,7 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
g.fill(x,y,getRight(),getBottom(),0x90151B19);
g.enableScissor(x,y,getRight(),getBottom());
if(readable) {
double zoom=Math.max(.75,Math.max(width,height)/992.);
double zoom=zoom();
int left=x+width/2+(int)((originX-centerX)*zoom),top=y+height/2+(int)((originZ-centerZ)*zoom);
g.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,left,top,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
int px=x+width/2,pz=y+height/2;
@@ -89,6 +99,8 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
int color=ProgressionClient.identity==null?0xFFFFFFFF:0xFF000000|ProgressionClient.identity.color();
g.fill(px-3,pz-3,px+4,pz+4,0xFF141818);g.fill(px-2,pz-2,px+3,pz+3,color);
}
boolean questHovered=CommunityMapMarkers.render(g,questPins(),mx,my);
setTooltip(questHovered?null:Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
g.text(font,"N ↑",x+7,y+7,0xFFFFFFFF);
if(pixels==0)g.textWithWordWrap(font,SanctuaryAtlasScreen.label(readable?"empty":status),x+8,y+height/2+12,width-16,0xFFCCCCCC);
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-14),x+7,getBottom()-16,0xFFF0EEE6);
@@ -62,7 +62,7 @@ final class PauseCommunityFeed {
var face=row.addChild(new PlayerFaceWidget(20,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId")))));
face.setTooltip(Tooltip.create(Component.literal(str(entry,"authorName"))));
row.addChild(Button.builder(title,b->action.run()).width(width-25).tooltip(Tooltip.create(title.copy().append("\n").append(str(entry,"authorName")))).build());
} else rows.addChild(new NoticeCard(width,entry,action));
} else rows.addChild(new CommunityCard(width,entry,true,action));
}
if(more && error.isEmpty())rows.addChild(Button.builder(text("more"),b->{page++;needsLoad=true;b.active=false;}).width(width).build());
scroll.arrangeElements();
@@ -73,22 +73,4 @@ final class PauseCommunityFeed {
}
void refresh() {if(page==0 && scroll.getScrollAmount()==0 && error.isEmpty())needsLoad=true;}
void close() {closed=true;}
private static final class NoticeCard extends AbstractButton {
private final JsonObject entry;
private final Runnable action;
NoticeCard(int width,JsonObject entry,Runnable action) {super(0,0,width,84,Component.literal(str(entry,"title")+"\n"+str(entry,"body")));this.entry=entry;this.action=action;}
@Override public void onPress(InputWithModifiers input) {action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
var font=Minecraft.getInstance().font;int x=getX(),y=getY(),border=isHoveredOrFocused()?0xFFC8C0A6:0xFF727365;
g.fill(x,y,getRight(),getBottom(),0xBA202720);
g.fill(x,y,getRight(),y+1,border);g.fill(x,getBottom()-1,getRight(),getBottom(),border);
g.fill(x,y,x+1,getBottom(),border);g.fill(getRight()-1,y,getRight(),getBottom(),border);
g.text(font,font.plainSubstrByWidth(str(entry,"title"),width-14),x+7,y+7,0xFFF0EEE6);
var lines=font.split(Component.literal(str(entry,"body")),width-14);
for(int i=0;i<Math.min(3,lines.size());i++)g.text(font,lines.get(i),x+7,y+23+i*10,0xFFD2D6CF,false);
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,y+height-23,0xFFAABAAA);
g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+height-12,0xFFAABAAA);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
}
}
@@ -38,7 +38,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
private String status="loading";
private String dimension="";
public SanctuaryAtlasScreen(Screen parent) {super(parent);}
private final fr.koka.sanctuary.community.CommunityTask.Location initialTarget;
public SanctuaryAtlasScreen(Screen parent) {this(parent,null);}
SanctuaryAtlasScreen(Screen parent,fr.koka.sanctuary.community.CommunityTask.Location target) {super(parent);initialTarget=target;}
public static Component label(String key,Object... args) {return Component.translatable("sanctuary.atlas."+key,args);}
@Override protected void init() {
dragging=false;
@@ -75,7 +77,10 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
minecraft.getTextureManager().register(TEXTURE,texture);
footprintTexture=new DynamicTexture("Sanctuary atlas footprints",SIZE,SIZE,false);
minecraft.getTextureManager().register(FOOTPRINT_TEXTURE,footprintTexture);
clear();recenter();
clear();
if(initialTarget!=null && minecraft.level!=null && initialTarget.dimension().equals(minecraft.level.dimension().identifier().toString())) {
centerX=initialTarget.x()+.5;centerZ=initialTarget.z()+.5;request();
} else recenter();
}
}
private void clear() {
@@ -193,6 +198,8 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
@Override public boolean mouseClicked(MouseButtonEvent event,boolean doubleClick) {
// Disabled buttons also own their hit area; clicking one must never start a map gesture.
if (overControl(event.x(),event.y())) return super.mouseClicked(event,doubleClick);
var quest=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==InputConstants.MOUSE_BUTTON_LEFT && quest!=null){CommunityClient.open(this,"notice",quest.marker().id(),false);return true;}
if (event.button()==InputConstants.MOUSE_BUTTON_LEFT && inside(event.x(),event.y()) && readable()) {
setFocused(null);setDragging(false);dragging=true;return true;
}
@@ -267,13 +274,18 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
graphics.textWithWordWrap(font,message,x,y,textWidth,0xFFE0E6EE);
}
renderCoordinates(graphics,mouseX,mouseY);
if (!renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
boolean questHint=CommunityMapMarkers.render(graphics,questPins(),mouseX,mouseY);
if (!questHint && !renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
}
public void accept(fr.koka.sanctuary.atlas.AtlasMarkerPayload payload) {
if (payload.token()!=token || !readable() || minecraft.level==null
|| !dimension.equals(minecraft.level.dimension().identifier().toString())) return;
markers=payload.markers();
}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,this::screenX,this::screenZ,0,contentTop,width,height-62).stream()
.filter(pin->!overControl(pin.x(),pin.y())).toList();
}
private Component markerName(fr.koka.sanctuary.atlas.AtlasMarker marker) {
return marker.name().isBlank() ? label("marker_"+marker.kind()) : Component.literal(marker.name());
}
@@ -4,7 +4,7 @@ import java.util.*;
/** Versioned community rules shared by both persistence adapters. */
public final class Community {
public static final int VERSION = 1, PAGE_SIZE = 10;
public static final int VERSION = 3, PAGE_SIZE = 10;
public static final Set<String> KINDS = Set.of("article", "notice", "bulletin", "reply");
public static final Set<String> CATEGORIES = Set.of("info", "work", "need", "event");
private Community() { }
@@ -13,8 +13,12 @@ public final class Community {
}
public record Entry(String id, String parentId, String kind, String category,
String authorId, String authorName, String title, String body,
String status, boolean pinned, int revision, long createdAt, long updatedAt) {
String status, boolean pinned, int revision, long createdAt, long updatedAt, String photo, CommunityTask task) {
public Entry {
task = kind.equals("notice")?Objects.requireNonNullElse(task,CommunityTask.EMPTY):task;
if(!kind.equals("notice") && task!=null)fail("task_invalid");
photo = Objects.requireNonNullElse(photo, "");
if (photo.length() > CommunityPhoto.MAX_TEXT || (!photo.isEmpty() && !kind.equals("article"))) fail("photo_invalid");
uuid(id); uuid(authorId); if (!parentId.isEmpty()) uuid(parentId);
if (!KINDS.contains(kind) || !CATEGORIES.contains(category)
|| !Set.of("open", "closed", "hidden").contains(status) || revision < 1
@@ -25,7 +29,14 @@ public final class Community {
}
}
public record Change(String action, String id, String parentId, String kind, String category,
String title, String body, int revision) { }
String title, String body, int revision, String photo, CommunityTask task) {
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision,String photo) {
this(action,id,parentId,kind,category,title,body,revision,photo,null);
}
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision) {
this(action,id,parentId,kind,category,title,body,revision,null,null);
}
}
public record Page(List<Entry> entries, int page, boolean more) { }
public record ThreadView(Entry entry, Page replies) { }
public static final class Refused extends RuntimeException {
@@ -33,6 +44,12 @@ public final class Community {
public Refused(String code) { super(code); this.code = code; }
}
public static void fail(String code) { throw new Refused(code); }
public static String search(String value) {
if(value==null || value.codePointCount(0,value.length())>120)fail("invalid");
value=value.strip();
if(!value.isEmpty())text(value,120);
return value.toLowerCase(Locale.ROOT);
}
public static String uuid(String value) {
if (value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) fail("invalid");
return value;
@@ -59,21 +76,30 @@ public final class Community {
if (existing != null) {
if (!existing.authorId().equals(actor.id()) || !existing.kind().equals(change.kind())
|| !existing.parentId().equals(parent) || !existing.category().equals(change.category())
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())) fail("conflict");
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())
|| !existing.photo().equals(Objects.requireNonNullElse(change.photo(),""))
|| !Objects.equals(existing.task(),change.kind().equals("notice")?Objects.requireNonNullElse(change.task(),CommunityTask.EMPTY):change.task())) fail("conflict");
return visible(existing);
}
if (!parent.isEmpty() && !tx.get(parent).status().equals("open")) fail("closed");
Entry entry = new Entry(change.id(), parent, change.kind(), change.category(), actor.id(), actor.name(),
change.title(), change.body(), "open", false, 1, now, now);
change.title(), change.body(), "open", false, 1, now, now, photo(change.kind(),change.photo()),change.task());
tx.save(entry, true); return entry;
}
Entry old = visible(tx.get(change.id()));
if(old.kind().equals("bulletin")&&!actor.moderator())fail("forbidden");
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
if (old.revision() != change.revision()) fail("conflict");
boolean own = old.authorId().equals(actor.id());
String title = old.title(), body = old.body(), status = old.status(); boolean pinned = old.pinned();
CommunityTask task=old.task();String category=old.category();
String title = old.title(), body = old.body(), status = old.status(), photo = old.photo(); boolean pinned = old.pinned();
switch (change.action()) {
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); }
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); photo = photo(old.kind(),change.photo()==null?old.photo():change.photo());
if(old.kind().equals("notice")) {
task=change.task()==null?old.task():change.task();category=change.category();
if(task.audience().equals("single") && tx.followers(old.id())>1)fail("notice_full");
}
}
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
case "close", "reopen" -> {
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
@@ -82,10 +108,15 @@ public final class Community {
case "pin" -> { if (!actor.moderator() || old.kind().equals("reply")) fail("forbidden"); pinned = !pinned; }
default -> fail("invalid");
}
Entry next = new Entry(old.id(), old.parentId(), old.kind(), old.category(), old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()));
Entry next = new Entry(old.id(), old.parentId(), old.kind(), category, old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo, task);
tx.save(next, false); return next;
}
private static String photo(String kind,String photo) {
if(kind.equals("article"))return CommunityPhoto.validate(photo);
if(photo!=null && !photo.isEmpty())fail("photo_invalid");
return "";
}
public static Entry visible(Entry entry) {
if (entry == null || entry.status().equals("hidden")) fail("missing");
return entry;
@@ -33,20 +33,31 @@ public final class CommunityNetwork {
public record Response(int sequence, String json) implements CustomPacketPayload {
public static final Type<Response> TYPE = new Type<>(SanctuaryMod.id("community_response_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf, Response> CODEC = CustomPacketPayload.codec(
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,131072); }, b -> new Response(b.readVarInt(),b.readUtf(131072)));
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,262144); }, b -> new Response(b.readVarInt(),b.readUtf(262144)));
public Type<Response> type() { return TYPE; }
}
private CommunityNetwork() { }
public static void register() {
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("link").then(net.minecraft.commands.Commands.argument("code",com.mojang.brigadier.arguments.StringArgumentType.word()).executes(context->{
request(context.getSource().getPlayerOrException(),new Request(0,"link","","","article","info","",com.mojang.brigadier.arguments.StringArgumentType.getString(context,"code"),0,0));return 1;
}))))));
net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->
dispatcher.register(net.minecraft.commands.Commands.literal("sanctuary").then(net.minecraft.commands.Commands.literal("community").then(net.minecraft.commands.Commands.literal("admin").requires(OperatorService::canCommand).executes(context->{
var player=context.getSource().getPlayerOrException();
if(ServerPlayNetworking.canSend(player,Response.TYPE))ServerPlayNetworking.send(player,new Response(0,"{\"admin\":true}"));
return 1;
})))));
PayloadTypeRegistry.serverboundPlay().register(Request.TYPE, Request.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Response.TYPE, Response.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE, (r,c) -> c.server().execute(() -> request(c.player(),r)));
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { var runtime = RUNTIMES.remove(server); if (runtime != null) runtime.stop(); });
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
var runtime = RUNTIMES.get(server); if (runtime != null) runtime.rates.remove(handler.player.getUUID());
var runtime = RUNTIMES.get(server); if (runtime != null) { runtime.rates.remove(handler.player.getUUID()); runtime.photos.remove(handler.player.getUUID()); runtime.tasks.remove(handler.player.getUUID()); }
});
}
private static void send(ServerPlayer player, Request request, JsonObject data) {
if(request.sequence()==0&&request.action().equals("link")){player.sendSystemMessage(net.minecraft.network.chat.Component.translatable("sanctuary.community."+(data.has("error")?data.get("error").getAsString():"linked")));return;}
if (!player.isRemoved() && ServerPlayNetworking.canSend(player, Response.TYPE))
ServerPlayNetworking.send(player, new Response(request.sequence(), data.toString()));
}
@@ -54,7 +65,7 @@ public final class CommunityNetwork {
private static void request(ServerPlayer player, Request request) {
var server = player.level().getServer();
var runtime = RUNTIMES.computeIfAbsent(server, Runtime::new);
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread").contains(request.action());
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread","photo","cards","following","task").contains(request.action());
long[] rates = runtime.rates.computeIfAbsent(player.getUUID(), key -> new long[2]); int index = reading ? 0 : 1;
if (runtime.pending.contains(player.getUUID()) || now - rates[index] < (reading ? 100_000_000L : 1_000_000_000L)) { send(player,request,error("busy")); return; }
rates[index] = now;
@@ -68,17 +79,61 @@ public final class CommunityNetwork {
if (System.nanoTime() - now > 10_000_000_000L) fail("busy");
var store = runtime.store(); result = new JsonObject(); result.addProperty("moderator",actor.moderator());
result.addProperty("serverTime",System.currentTimeMillis()); result.addProperty("mode",runtime.config.mode());
if(request.kind().equals("bulletin") && !Set.of("overview","following","list","cards","thread").contains(request.action()) && !actor.moderator())fail("forbidden");
switch (request.action()) {
case "overview" -> {
result.add("articles", JSON.toJsonTree(store.list("article","",0).entries().stream().limit(3).toList()));
result.add("notices", JSON.toJsonTree(store.list("notice","",0).entries().stream().limit(3).toList()));
result.add("bulletins", JSON.toJsonTree(store.list("bulletin","",0).entries().stream().limit(1).toList()));
}
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
case "thread" -> result.add("thread",JSON.toJsonTree(store.thread(request.id(),request.page())));
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page(),request.body())));
case "cards" -> {
if(!Set.of("article","notice").contains(request.kind()))fail("invalid");
var page=store.list(request.kind(),"",request.page(),request.body());var cards=JSON.toJsonTree(page).getAsJsonObject();
for(var value:cards.getAsJsonArray("entries")) {
var entry=value.getAsJsonObject();String photo=entry.has("photo")?entry.get("photo").getAsString():"";
if(!photo.isEmpty())entry.addProperty("thumbnail",runtime.thumbnail(entry.get("id").getAsString()+":"+entry.get("revision").getAsInt(),photo));
entry.remove("body");entry.remove("task");
}
omitPhotos(cards);result.add("page",cards);
}
case "thread" -> {
var thread=store.thread(request.id(),request.page());
result.add("thread",JSON.toJsonTree(thread));
if(thread.entry().kind().equals("notice")) {
var members=store.members(request.id());result.add("members",JSON.toJsonTree(members));
result.addProperty("subscribed",members.stream().anyMatch(f->f.playerId().equals(actor.id())));
}
}
case "follow","unfollow" -> {
store.follow(actor,request.id(),request.action().equals("follow"));
result.add("following",following(store,actor));result.addProperty("saved",true);
}
case "following" -> result.add("following",following(store,actor));
case "task" -> {
runtime.tasks.values().removeIf(t->System.currentTimeMillis()-t.at()>120000);
if(runtime.tasks.size()>=128 && !runtime.tasks.containsKey(player.getUUID()))fail("busy");
if(request.body().length()>16000)fail("task_invalid");
var task=JSON.fromJson(request.body(),CommunityTask.class);validateTask(server,task);
runtime.tasks.put(player.getUUID(),new TaskDraft(uuid(request.id()),task,System.currentTimeMillis()));
result.addProperty("saved",true);
}
case "photo" -> {
long instant=System.currentTimeMillis();
runtime.photos.values().removeIf(p -> instant-p.started>120000);
if(request.page()==0) {
if(runtime.photos.size()>=128 && !runtime.photos.containsKey(player.getUUID()))fail("busy");
runtime.photos.put(player.getUUID(),new CommunityPhoto.Transfer(request.id(),request.revision(),instant));
}
var transfer=runtime.photos.get(player.getUUID());
if(transfer==null)fail("photo_required");
transfer.append(request.id(),request.page(),request.revision(),request.body(),instant);
result.addProperty("uploaded",request.page()+1);
}
case "link" -> { store.link(actor,request.body()); result.addProperty("linked",true); }
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision()))));
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision(),runtime.photo(player.getUUID(),request),runtime.task(player.getUUID(),request)))));
}
if(request.action().equals("list") || request.action().equals("overview")) {omitPhotos(result);omitTasks(result);}
} catch (Refused e) { result = error(e.code); }
catch (Exception e) { result = error("unavailable"); LOG.warn("Community operation unavailable ({})",e.getClass().getSimpleName()); }
JsonObject response = result;
@@ -89,12 +144,58 @@ public final class CommunityNetwork {
});
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
}
private record TaskDraft(String id,CommunityTask task,long at) { }
private static void validateTask(MinecraftServer server,CommunityTask task) {
if(task==null)fail("task_invalid");
for(var item:java.util.stream.Stream.concat(task.materials().stream(),task.rewards().stream()).toList())
if(net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item())).isEmpty())fail("task_invalid");
if(task.location()!=null && server.levelKeys().stream().noneMatch(k->k.identifier().toString().equals(task.location().dimension())))fail("task_invalid");
}
private static JsonElement following(CommunityStore store,Actor actor) throws Exception {
var result=JSON.toJsonTree(store.following(actor));omitPhotos(result);
result.getAsJsonArray().forEach(e->{
var entry=e.getAsJsonObject();String body=entry.remove("body").getAsString();
int count=body.codePointCount(0,body.length());
entry.addProperty("summary",count>240?body.substring(0,body.offsetByCodePoints(0,240))+"…":body);
});return result;
}
private static void omitTasks(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitTasks);
else if(value.isJsonObject()) {var object=value.getAsJsonObject();object.remove("task");object.entrySet().forEach(e->omitTasks(e.getValue()));}
}
public static void omitPhotos(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitPhotos);
else if(value.isJsonObject()) {
var object=value.getAsJsonObject();
if(object.has("photo")) {object.addProperty("hasPhoto",!object.get("photo").getAsString().isEmpty());object.remove("photo");}
object.entrySet().forEach(e -> omitPhotos(e.getValue()));
}
}
private static final class Runtime {
final MinecraftServer server;
final ThreadPoolExecutor executor = new ThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(32),
task -> { var thread = new Thread(task,"Sanctuary community"); thread.setDaemon(true); return thread; });
final Map<UUID,long[]> rates = new HashMap<>();
final Set<UUID> pending = new HashSet<>();
final Map<UUID,CommunityPhoto.Transfer> photos=new ConcurrentHashMap<>();
String photo(UUID player,Request request) {
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("article"))return null;
var transfer=photos.get(player);
if(transfer==null || !transfer.id.equals(request.id()))return null;
return transfer.value(request.id(),System.currentTimeMillis());
}
final Map<UUID,TaskDraft> tasks=new ConcurrentHashMap<>();
final Map<String,String> thumbnails=new LinkedHashMap<>();
String thumbnail(String key,String photo) throws Exception {
if(thumbnails.containsKey(key))return thumbnails.get(key);
String result=CommunityPhoto.thumbnail(photo);if(thumbnails.size()>=128)thumbnails.remove(thumbnails.keySet().iterator().next());
thumbnails.put(key,result);return result;
}
CommunityTask task(UUID player,Request request) {
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("notice"))return null;
var draft=tasks.get(player);if(draft==null || !draft.id().equals(request.id()))return null;
if(System.currentTimeMillis()-draft.at()>120000)fail("task_expired");return draft.task();
}
CommunityConfig config;
CommunityStore store;
Runtime(MinecraftServer server) { this.server = server; }
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.community;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.Base64;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.stream.MemoryCacheImageInputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import static fr.koka.sanctuary.community.Community.fail;
/** Bounded, portable JPEG copies; originals and their metadata never leave the screenshot folder. */
public final class CommunityPhoto {
public static final int MAX_BYTES=48*1024, MAX_TEXT=65536, WIDTH=960, HEIGHT=540;
private CommunityPhoto() { }
public static final class Transfer {
public final String id;
public final long started;
private final int parts;
private int next;
private final StringBuilder data=new StringBuilder();
public Transfer(String id,int parts,long now) {
this.id=Community.uuid(id);this.parts=parts;this.started=now;
if(parts<1 || parts>9)fail("photo_invalid");
}
public void append(String id,int part,int total,String value,long now) {
if(!this.id.equals(id) || part!=next || part>=parts || total!=parts || now-started>120000
|| value.isEmpty() || value.length()>8000 || data.length()+value.length()>MAX_TEXT)fail("photo_invalid");
data.append(value);next++;
}
public String value(String id,long now) {
if(!this.id.equals(id) || next!=parts || now-started>120000)fail("photo_required");
return data.toString();
}
}
public static BufferedImage decode(byte[] bytes,int maxWidth,int maxHeight) throws IOException {
try(var stream=new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) {
var readers=ImageIO.getImageReaders(stream);
if(!readers.hasNext())throw new IOException("Invalid image");
var reader=readers.next();
try {
reader.setInput(stream,true,true);
int w=reader.getWidth(0),h=reader.getHeight(0);
if(w<1 || h<1 || w>maxWidth || h>maxHeight || (long)w*h>32_000_000)throw new IOException("Image dimensions");
var image=reader.read(0);
if(image==null)throw new IOException("Invalid image");
return image;
} finally {reader.dispose();}
}
}
public static String validate(String value) {
if(value==null || value.isEmpty())fail("photo_required");
try {
if(value.length()>MAX_TEXT)throw new IOException("Image size");
byte[] bytes=Base64.getDecoder().decode(value);
if(bytes.length>MAX_BYTES || bytes.length<4 || bytes[0]!=(byte)0xff || bytes[1]!=(byte)0xd8
|| bytes[bytes.length-2]!=(byte)0xff || bytes[bytes.length-1]!=(byte)0xd9
|| !Base64.getEncoder().encodeToString(bytes).equals(value))throw new IOException("Invalid JPEG");
decode(bytes,WIDTH,HEIGHT);
return value;
} catch(IOException | IllegalArgumentException e) {fail("photo_invalid");return "";}
}
public static String thumbnail(String photo) throws IOException {
if(photo.isEmpty())return "";
return encode(decode(Base64.getDecoder().decode(photo),WIDTH,HEIGHT),160,90,6144);
}
public static String encode(BufferedImage source,int maxWidth,int maxHeight) throws IOException {return encode(source,maxWidth,maxHeight,MAX_BYTES);}
private static String encode(BufferedImage source,int maxWidth,int maxHeight,int limit) throws IOException {
double scale=Math.min(1,Math.min((double)maxWidth/source.getWidth(),(double)maxHeight/source.getHeight()));
var image=new BufferedImage(Math.max(1,(int)(source.getWidth()*scale)),Math.max(1,(int)(source.getHeight()*scale)),BufferedImage.TYPE_INT_RGB);
var graphics=image.createGraphics();
graphics.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.drawImage(source,0,0,image.getWidth(),image.getHeight(),null);graphics.dispose();
for(float quality:new float[]{.85f,.7f,.55f,.4f,.25f}) {
var bytes=new ByteArrayOutputStream();
var writer=ImageIO.getImageWritersByFormatName("jpeg").next();
try(var out=new MemoryCacheImageOutputStream(bytes)) {
writer.setOutput(out);var params=writer.getDefaultWriteParam();
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);params.setCompressionQuality(quality);
writer.write(null,new javax.imageio.IIOImage(image,null,null),params);
} finally {writer.dispose();}
if(bytes.size()<=limit)return Base64.getEncoder().encodeToString(bytes.toByteArray());
}
if(maxWidth>60)return encode(image,maxWidth/2,maxHeight/2,limit);
throw new IOException("Image size");
}
}
@@ -7,10 +7,15 @@ public interface CommunityStore extends AutoCloseable {
interface Transaction {
Entry get(String id) throws Exception;
void save(Entry entry, boolean insert) throws Exception;
int followers(String id) throws Exception;
}
Page list(String kind, String parent, int page) throws Exception;
default Page list(String kind, String parent, int page) throws Exception { return list(kind,parent,page,""); }
Page list(String kind, String parent, int page, String search) throws Exception;
ThreadView thread(String id, int page) throws Exception;
Entry change(Actor actor, Change change) throws Exception;
java.util.List<CommunityTask.Follow> members(String id) throws Exception;
java.util.List<Entry> following(Actor actor) throws Exception;
void follow(Actor actor,String id,boolean join) throws Exception;
default void link(Actor actor, String code) throws Exception { fail("database_only"); }
@Override default void close() throws Exception { }
}
@@ -0,0 +1,46 @@
package fr.koka.sanctuary.community;
import java.util.*;
import static fr.koka.sanctuary.community.Community.*;
/** Descriptive requests only. No ItemStack, inventory mutation, delivery or reward ledger. */
public record CommunityTask(Location location,List<Material> materials,String audience,String reward,
List<Material> rewards,String customReward) {
public static final int MAX_ITEMS=27, MAX_FOLLOWS=16, MAX_MEMBERS=64;
public static final CommunityTask EMPTY=new CommunityTask(null,List.of(),"shared","none",List.of(),"");
public record Material(String item,int count) {
public Material {identifier(item);if(item.equals("minecraft:air") || count<1 || count>999999)fail("task_invalid");}
}
public record Location(String dimension,int x,int y,int z) {
public Location {identifier(dimension);if(Math.abs((long)x)>29999984 || Math.abs((long)z)>29999984 || y< -4096 || y>4096)fail("task_invalid");}
}
public CommunityTask {
materials=items(materials);rewards=items(rewards);
if(audience==null || reward==null || !Set.of("shared","single").contains(audience) || !Set.of("none","items","custom").contains(reward))fail("task_invalid");
if(customReward==null)fail("task_invalid");
if(reward.equals("custom"))text(customReward,240);else if(!customReward.isEmpty())fail("task_invalid");
if(reward.equals("items")?rewards.isEmpty():!rewards.isEmpty())fail("task_invalid");
}
private static List<Material> items(List<Material> items) {
if(items==null || items.size()>MAX_ITEMS)fail("task_invalid");
var ids=new HashSet<String>();for(var item:items)if(item==null || !ids.add(item.item()))fail("task_invalid");
return List.copyOf(items);
}
public static String identifier(String id) {
if(id==null || id.length()>100 || !id.matches("[a-z0-9_.-]+:[a-z0-9_./-]+"))fail("task_invalid");
return id;
}
public record Follow(String noticeId,String playerId,String playerName,long since) {
public Follow {uuid(noticeId);uuid(playerId);text(playerName,64);if(since<0)fail("invalid");}
}
public static void canFollow(Entry entry,Actor actor,boolean join,List<Follow> members,int ownCount) {
if(!entry.kind().equals("notice"))fail("invalid");
if(!join)return;
if(entry.status().equals("hidden"))fail("missing");
if(members.stream().anyMatch(f->f.playerId().equals(actor.id())))return;
if(!entry.status().equals("open"))fail("closed");
if(entry.authorId().equals(actor.id()))fail("own_notice");
if(ownCount>=MAX_FOLLOWS)fail("follow_limit");
if(members.size()>=MAX_MEMBERS || entry.task().audience().equals("single")&&!members.isEmpty())fail("notice_full");
}
}
@@ -15,6 +15,8 @@ public final class FileCommunityStore implements CommunityStore {
private final String serverId;
private final Clock clock;
private byte[] previous;
private int legacy;
private java.util.List<CommunityTask.Follow> followers=new ArrayList<>();
private Map<String, Entry> entries = new LinkedHashMap<>();
public FileCommunityStore(Path path, String serverId) throws Exception { this(path, serverId, Clock.systemUTC()); }
public FileCommunityStore(Path path, String serverId, Clock clock) throws Exception {
@@ -22,46 +24,75 @@ public final class FileCommunityStore implements CommunityStore {
previous = AtomicJson.read(path, LIMIT);
if (previous != null) {
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "version", "serverId", "entries");
if (root.get("version").getAsInt() != VERSION || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
legacy = root.get("version").getAsInt();
if(legacy<3)AtomicJson.fields(root,"version","serverId","entries");else AtomicJson.fields(root,"version","serverId","entries","followers");
if ((legacy<1 || legacy>VERSION) || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
if(root.has("followers"))for(var f:root.getAsJsonArray("followers"))followers.add(JSON.fromJson(f,CommunityTask.Follow.class));
for (var value : root.getAsJsonArray("entries")) {
Entry entry = JSON.fromJson(value, Entry.class);
if (entries.put(entry.id(), entry) != null) fail("invalid");
}
var unique=new HashSet<String>();
for(var f:followers)if(!unique.add(f.noticeId()+f.playerId()) || !entries.containsKey(f.noticeId()) || !entries.get(f.noticeId()).kind().equals("notice"))fail("invalid");
for (Entry entry : entries.values()) if (!entry.parentId().isEmpty()) {
Entry parent = entries.get(entry.parentId());
if (parent == null || !Set.of("article", "notice").contains(parent.kind())) fail("invalid");
}
}
}
@Override public synchronized Page list(String kind, String parent, int page) {
@Override public synchronized Page list(String kind, String parent, int page, String search) {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(entries.get(parent));
Comparator<Entry> order = Comparator.comparingLong(Entry::createdAt).thenComparing(Entry::id);
if (kind.equals("bulletin")) order = order.reversed();
else if (!kind.equals("reply")) order = Comparator.comparing(Entry::pinned).reversed().thenComparing(order.reversed());
var found = entries.values().stream().filter(e -> e.kind().equals(kind) && e.parentId().equals(parent)
&& !e.status().equals("hidden")).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
&& !e.status().equals("hidden") && (needle.isEmpty() || e.authorName().toLowerCase(Locale.ROOT).contains(needle)
|| e.title().toLowerCase(Locale.ROOT).contains(needle) || e.body().toLowerCase(Locale.ROOT).contains(needle))).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
return new Page(found.subList(0, Math.min(PAGE_SIZE, found.size())), page, found.size() > PAGE_SIZE);
}
@Override public synchronized ThreadView thread(String id, int page) {
Entry entry = visible(entries.get(uuid(id)));
if (entry.kind().equals("reply")) fail("missing");
return new ThreadView(entry, list("reply", id, page));
return new ThreadView(entry, list("reply", id, page, ""));
}
@Override public synchronized Entry change(Actor actor, Change change) throws Exception {
var candidate = new LinkedHashMap<>(entries);
boolean[] dirty = {false};
Entry result = Community.apply(new Transaction() {
public int followers(String id) {return members(id).size();}
public Entry get(String id) { return candidate.get(id); }
public void save(Entry entry, boolean insert) { candidate.put(entry.id(), entry); dirty[0] = true; }
}, actor, change, clock.millis());
if (dirty[0]) {
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
root.add("entries", JSON.toJsonTree(candidate.values()));
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
entries = candidate;
persist(candidate,result.status().equals("hidden")?followers.stream().filter(f->!f.noticeId().equals(result.id())).toList():followers);
}
return result;
}
private void persist(Map<String,Entry> candidate,List<CommunityTask.Follow> nextFollowers) throws Exception {
var root=new JsonObject();root.addProperty("version",VERSION);root.addProperty("serverId",serverId);
root.add("entries",JSON.toJsonTree(candidate.values()));root.add("followers",JSON.toJsonTree(nextFollowers));
if(previous!=null && legacy<VERSION) {
Path backup=path.resolveSibling(path.getFileName()+".v"+legacy+".bak");
if(Files.exists(backup)) {if(!Arrays.equals(AtomicJson.read(backup,LIMIT),previous))throw new java.io.IOException("Different migration backup");}
else Files.write(backup,previous,StandardOpenOption.CREATE_NEW);
}
previous=AtomicJson.write(path,previous,root.toString(),LIMIT);entries=candidate;followers=nextFollowers;legacy=VERSION;
}
@Override public synchronized List<CommunityTask.Follow> members(String id) {return followers.stream().filter(f->f.noticeId().equals(id)).toList();}
@Override public synchronized List<Entry> following(Actor actor) {
return followers.stream().filter(f->f.playerId().equals(actor.id())).sorted(Comparator.comparingLong(CommunityTask.Follow::since).reversed().thenComparing(CommunityTask.Follow::noticeId))
.map(f->entries.get(f.noticeId())).filter(e->e!=null&&!e.status().equals("hidden")).toList();
}
@Override public synchronized void follow(Actor actor,String id,boolean join) throws Exception {
Entry entry=entries.get(uuid(id));if(entry==null)fail("missing");
CommunityTask.canFollow(entry,actor,join,members(id),(int)followers.stream().filter(f->f.playerId().equals(actor.id())).count());
boolean exists=followers.stream().anyMatch(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
if(exists==join)return;
var next=new ArrayList<>(followers);
if(join)next.add(new CommunityTask.Follow(id,actor.id(),actor.name(),clock.millis()));
else next.removeIf(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
persist(entries,next);
}
}
@@ -9,6 +9,7 @@ import static fr.koka.sanctuary.community.Community.*;
/** JDBC stays on the community worker. No credentials or raw SQL errors reach players. */
public final class SqlCommunityStore implements CommunityStore {
private static final com.google.gson.Gson JSON=new com.google.gson.Gson();
private final String url, user, password, serverId;
private final Clock clock;
public SqlCommunityStore(String url, String user, String password, String serverId) throws Exception {
@@ -35,33 +36,36 @@ public final class SqlCommunityStore implements CommunityStore {
return new Entry(r.getString("id"), Objects.requireNonNullElse(r.getString("parent_id"), ""), r.getString("kind"),
r.getString("category"), r.getString("author_id"), r.getString("author_name"), r.getString("title"),
r.getString("body"), r.getString("status"), r.getBoolean("pinned"), r.getInt("revision"),
r.getLong("created_at"), r.getLong("updated_at"));
r.getLong("created_at"), r.getLong("updated_at"), r.getString("photo"), r.getString("task")==null?null:JSON.fromJson(r.getString("task"),CommunityTask.class));
}
private Entry get(Connection c, String id, boolean lock) throws SQLException {
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
var r = s.executeQuery()) { return r.next() ? row(r) : null; }
}
private Page list(Connection c, String kind, String parent, int page) throws Exception {
private Page list(Connection c, String kind, String parent, int page, String search) throws Exception {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(get(c, uuid(parent), false));
String parentSql = parent.isEmpty() ? "parent_id IS NULL" : "parent_id=?";
String order = kind.equals("reply") ? "created_at ASC,id ASC" : kind.equals("bulletin") ? "created_at DESC,id DESC" : "pinned DESC,created_at DESC,id DESC";
var values = new ArrayList<Object>(List.of(serverId, kind)); if (!parent.isEmpty()) values.add(parent);
String filter=needle.isEmpty()?"":" AND (LOCATE(?,BINARY LOWER(author_name))>0 OR LOCATE(?,BINARY LOWER(title))>0 OR LOCATE(?,BINARY LOWER(body))>0)";
if(!needle.isEmpty())values.addAll(List.of(needle,needle,needle));
values.add(PAGE_SIZE + 1); values.add(page * PAGE_SIZE);
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND kind=? AND " + parentSql
+ " AND status<>'hidden' ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
+ " AND status<>'hidden'" + filter + " ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
var entries = new ArrayList<Entry>(); while (r.next()) entries.add(row(r));
return new Page(List.copyOf(entries.subList(0, Math.min(PAGE_SIZE, entries.size()))), page, entries.size() > PAGE_SIZE);
}
}
@Override public Page list(String kind, String parent, int page) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page); c.commit(); return result; }
@Override public Page list(String kind, String parent, int page, String search) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page, search); c.commit(); return result; }
}
@Override public ThreadView thread(String id, int page) throws Exception {
try (var c = connect()) {
c.setAutoCommit(false); Entry entry = visible(get(c, uuid(id), false));
if (entry.kind().equals("reply")) fail("missing");
var result = new ThreadView(entry, list(c, "reply", id, page)); c.commit(); return result;
var result = new ThreadView(entry, list(c, "reply", id, page, "")); c.commit(); return result;
}
}
@Override public Entry change(Actor actor, Change change) throws Exception {
@@ -69,14 +73,16 @@ public final class SqlCommunityStore implements CommunityStore {
c.setAutoCommit(false);
try {
Entry result = Community.apply(new Transaction() {
public int followers(String id) throws Exception {return members(c,id).size();}
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
public void save(Entry e, boolean insert) throws Exception {
if(e.status().equals("hidden"))try(var s=query(c,"DELETE FROM sanctuary_community_followers WHERE server_id=? AND notice_id=?",serverId,e.id())){s.executeUpdate();}
if (insert) {
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt())) { s.executeUpdate(); }
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at,photo,task) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt(), e.photo().isEmpty()?null:e.photo(),e.task()==null?null:JSON.toJson(e.task()))) { s.executeUpdate(); }
} else {
try (var s = query(c, "UPDATE sanctuary_community_entries SET title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
try (var s = query(c, "UPDATE sanctuary_community_entries SET task=?,category=?,photo=?,title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.task()==null?null:JSON.toJson(e.task()), e.category(), e.photo().isEmpty()?null:e.photo(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
if (s.executeUpdate() != 1) fail("conflict");
}
}
@@ -86,6 +92,35 @@ public final class SqlCommunityStore implements CommunityStore {
} catch (Exception error) { c.rollback(); throw error; }
}
}
private List<CommunityTask.Follow> members(Connection c,String id) throws Exception {
try(var s=query(c,"SELECT * FROM sanctuary_community_followers WHERE server_id=? AND notice_id=? ORDER BY subscribed_at,player_id",serverId,id);var r=s.executeQuery()) {
var result=new ArrayList<CommunityTask.Follow>();while(r.next())result.add(new CommunityTask.Follow(id,r.getString("player_id"),r.getString("player_name"),r.getLong("subscribed_at")));return result;
}
}
@Override public List<CommunityTask.Follow> members(String id) throws Exception {try(var c=connect()){return members(c,uuid(id));}}
@Override public List<Entry> following(Actor actor) throws Exception {
try(var c=connect();var s=query(c,"SELECT e.* FROM sanctuary_community_entries e JOIN sanctuary_community_followers f ON e.server_id=f.server_id AND e.id=f.notice_id WHERE e.server_id=? AND f.player_id=? AND e.status<>'hidden' ORDER BY f.subscribed_at DESC,e.id LIMIT 16",serverId,actor.id());var r=s.executeQuery()) {
var result=new ArrayList<Entry>();while(r.next())result.add(row(r));return List.copyOf(result);
}
}
@Override public void follow(Actor actor,String id,boolean join) throws Exception {
uuid(id);
try(var c=connect()) {
c.setAutoCommit(false);
try {
Entry entry=get(c,id,true);if(entry==null)fail("missing");
try(var s=query(c,"INSERT IGNORE INTO sanctuary_community_subscribers(server_id,player_id) VALUES (?,?)",serverId,actor.id())){s.executeUpdate();}
try(var s=query(c,"SELECT player_id FROM sanctuary_community_subscribers WHERE server_id=? AND player_id=? FOR UPDATE",serverId,actor.id());var r=s.executeQuery()){r.next();}
int count;
try(var s=query(c,"SELECT COUNT(*) FROM sanctuary_community_followers WHERE server_id=? AND player_id=?",serverId,actor.id());var r=s.executeQuery()){r.next();count=r.getInt(1);}
var members=members(c,id);CommunityTask.canFollow(entry,actor,join,members,count);
boolean exists=members.stream().anyMatch(f->f.playerId().equals(actor.id()));
if(join&&!exists)try(var s=query(c,"INSERT INTO sanctuary_community_followers(server_id,notice_id,player_id,player_name,subscribed_at) VALUES (?,?,?,?,?)",serverId,id,actor.id(),actor.name(),clock.millis())){s.executeUpdate();}
if(!join&&exists)try(var s=query(c,"DELETE FROM sanctuary_community_followers WHERE server_id=? AND notice_id=? AND player_id=?",serverId,id,actor.id())){s.executeUpdate();}
c.commit();
}catch(Exception e){c.rollback();throw e;}
}
}
@Override public void link(Actor actor, String code) throws Exception {
if (!code.matches("[0-9a-f]{32}")) fail("invalid");
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(code.getBytes(StandardCharsets.US_ASCII)));
@@ -2341,6 +2341,13 @@
"sanctuary.shaders.ssr_distance_help": "Independent SSR range: 16, 32, 64, 128 or 256 blocks. Default: 64 blocks. Limited to visible terrain and mesh budget.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Notice board",
"sanctuary.community.notice_tab": "Notice board",
"sanctuary.community.map_followed": "Followed request · click for discussion",
"sanctuary.community.map_more_materials": "+ %s more materials",
"sanctuary.community.map_show": "Show on map",
"sanctuary.community.map_other_dimension": "Visit this dimension to open its map.",
"sanctuary.community.search": "Search: player, title or text…",
"sanctuary.community.server_tab": "Server",
"sanctuary.community.bulletin": "Server notices",
"sanctuary.community.publish": "Publish",
"sanctuary.community.reply": "Reply",
@@ -2394,5 +2401,43 @@
"sanctuary.pause.map": "World map",
"sanctuary.pause.map_hint": "Open the map and explore",
"sanctuary.community.more": "Load more",
"sanctuary.community.browse": "All publications"
"sanctuary.community.browse": "All publications",
"sanctuary.community.photo": "Article photo",
"sanctuary.community.photo_choose": "Choose a screenshot…",
"sanctuary.community.photo_change": "Change photo…",
"sanctuary.community.photo_assign": "Assign to article",
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
"sanctuary.community.photo_uploading": "Uploading photo…",
"sanctuary.community.screenshots": "Screenshots",
"sanctuary.community.screenshots_back": "Back to screenshots",
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
"sanctuary.community.screenshots_error": "Could not read screenshots.",
"sanctuary.community.photo_unavailable": "Photo unavailable",
"sanctuary.community.task_configure": "Request and rewards",
"sanctuary.community.task_summary": "%s materials · %s",
"sanctuary.community.location_current": "Use my location",
"sanctuary.community.location_remove": "Remove location",
"sanctuary.community.location_value": "%s · %s, %s, %s",
"sanctuary.community.materials": "Requested materials",
"sanctuary.community.material_search": "Search materials",
"sanctuary.community.selected_materials": "Descriptive chest · %s / %s",
"sanctuary.community.quantity": "Quantity",
"sanctuary.community.remove_material": "Remove",
"sanctuary.community.audience.shared": "Multiple participants · shared reward",
"sanctuary.community.audience.single": "One participant",
"sanctuary.community.reward.none": "No reward",
"sanctuary.community.reward.items": "Item reward",
"sanctuary.community.reward.custom": "Custom reward",
"sanctuary.community.rewards_choose": "Choose reward items",
"sanctuary.community.exchange_help": "Descriptive tracking: players arrange exchanges and reward sharing themselves.",
"sanctuary.community.followers": "%s follower(s)",
"sanctuary.community.follow": "Follow request",
"sanctuary.community.unfollow": "Unfollow",
"sanctuary.community.followed": "Following request: %s",
"sanctuary.community.task_invalid": "Check materials, quantities and reward.",
"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."
}
@@ -2341,6 +2341,13 @@
"sanctuary.shaders.ssr_distance_help": "Portée indépendante du SSR : 16, 32, 64, 128 ou 256 blocs. Défaut : 64 blocs. Limitée au terrain visible et au budget de maillage.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Tableau",
"sanctuary.community.notice_tab": "Tableau",
"sanctuary.community.map_followed": "Demande suivie · cliquer pour la discussion",
"sanctuary.community.map_more_materials": "+ %s autres matériaux",
"sanctuary.community.map_show": "Voir sur la carte",
"sanctuary.community.map_other_dimension": "Rejoignez cette dimension pour ouvrir sa carte.",
"sanctuary.community.search": "Rechercher : joueur, titre ou texte…",
"sanctuary.community.server_tab": "Serveur",
"sanctuary.community.bulletin": "Intendance",
"sanctuary.community.publish": "Publier",
"sanctuary.community.reply": "Répondre",
@@ -2393,6 +2400,44 @@
"sanctuary.community.reply_hint": "Écrire une réponse…",
"sanctuary.pause.map": "Carte du monde",
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
"sanctuary.community.more": "Afficher la suite",
"sanctuary.community.browse": "Toutes les publications"
"sanctuary.community.more": "Charger la suite",
"sanctuary.community.browse": "Toutes les publications",
"sanctuary.community.photo": "Photo de l’article",
"sanctuary.community.photo_choose": "Choisir une capture…",
"sanctuary.community.photo_change": "Changer la photo…",
"sanctuary.community.photo_assign": "Assigner à l’article",
"sanctuary.community.photo_required": "Choisissez une photo pour publier cet article.",
"sanctuary.community.photo_invalid": "Photo illisible ou trop grande.",
"sanctuary.community.photo_uploading": "Envoi de la photo…",
"sanctuary.community.screenshots": "Captures d’écran",
"sanctuary.community.screenshots_back": "Retour aux captures",
"sanctuary.community.screenshots_empty": "Aucune capture. Prenez une photo en jeu avec F2, puis revenez ici.",
"sanctuary.community.screenshots_error": "Impossible de lire les captures.",
"sanctuary.community.photo_unavailable": "Photo indisponible",
"sanctuary.community.task_configure": "Demande et récompenses",
"sanctuary.community.task_summary": "%s matériaux · %s",
"sanctuary.community.location_current": "Utiliser ma position",
"sanctuary.community.location_remove": "Retirer la localisation",
"sanctuary.community.location_value": "%s · %s, %s, %s",
"sanctuary.community.materials": "Matériaux demandés",
"sanctuary.community.material_search": "Rechercher un matériau",
"sanctuary.community.selected_materials": "Coffre descriptif · %s / %s",
"sanctuary.community.quantity": "Quantité",
"sanctuary.community.remove_material": "Retirer",
"sanctuary.community.audience.shared": "Plusieurs participants · partage",
"sanctuary.community.audience.single": "Un seul participant",
"sanctuary.community.reward.none": "Sans récompense",
"sanctuary.community.reward.items": "Récompense en objets",
"sanctuary.community.reward.custom": "Récompense personnalisée",
"sanctuary.community.rewards_choose": "Choisir les objets proposés",
"sanctuary.community.exchange_help": "Suivi descriptif : les joueurs organisent les échanges et le partage des récompenses entre eux.",
"sanctuary.community.followers": "%s participant(s)",
"sanctuary.community.follow": "Suivre la demande",
"sanctuary.community.unfollow": "Ne plus suivre",
"sanctuary.community.followed": "Demande suivie : %s",
"sanctuary.community.task_invalid": "Vérifiez les matériaux, les quantités et la récompense.",
"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."
}
@@ -0,0 +1,3 @@
-- Stop both applications and back up the database first. Apply once to schema v1.
ALTER TABLE sanctuary_community_entries ADD COLUMN photo MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NULL;
UPDATE sanctuary_community_schema SET version=2 WHERE id=1 AND version=1;
@@ -0,0 +1,13 @@
-- Stop both writers, back up, and apply once to schema v2.
ALTER TABLE sanctuary_community_entries ADD COLUMN task TEXT NULL;
CREATE TABLE sanctuary_community_subscribers (
server_id CHAR(36) NOT NULL, player_id CHAR(36) NOT NULL,
PRIMARY KEY(server_id,player_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE sanctuary_community_followers (
server_id CHAR(36) NOT NULL, notice_id CHAR(36) NOT NULL,
player_id CHAR(36) NOT NULL, player_name VARCHAR(64) NOT NULL, subscribed_at BIGINT NOT NULL,
PRIMARY KEY(server_id,notice_id,player_id),
KEY community_followed(server_id,player_id,subscribed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
UPDATE sanctuary_community_schema SET version=3 WHERE id=1 AND version=2;
@@ -15,9 +15,10 @@ public final class CommunityInterop {
JsonObject fixture;
if(phase.equals("seed")) {
fixture=new JsonObject();
for(String key:new String[]{"server","player","article"})fixture.addProperty(key,UUID.randomUUID().toString());
for(String key:new String[]{"server","player","article","notice"})fixture.addProperty(key,UUID.randomUUID().toString());
var store=new SqlCommunityStore(url,"root","",fixture.get("server").getAsString());
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0));
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0,CommunitySmoke.PHOTO));
store.change(new Actor(UUID.randomUUID().toString(),"Voisin",false),new Change("create",fixture.get("notice").getAsString(),"","notice","need","Le pont","Bois demandé",0,null,new CommunityTask(new CommunityTask.Location("minecraft:overworld",12,64,-4),java.util.List.of(new CommunityTask.Material("minecraft:oak_log",64)),"single","custom",java.util.List.of(),"Un repas")));
Files.writeString(file,json.toJson(fixture));
} else {
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
@@ -30,8 +31,14 @@ public final class CommunityInterop {
CommunitySmoke.refuse("link_expired",()->store.link(actor,code));
CommunitySmoke.refuse("link_expired",()->new SqlCommunityStore(url,"root","",UUID.randomUUID().toString()).link(actor,code));
} else if(phase.equals("verify")) {
var notice=store.thread(fixture.get("notice").getAsString(),0).entry();
CommunitySmoke.check(store.following(actor).stream().anyMatch(e->e.id().equals(notice.id())),"Website subscription visible in Java");
var webNotice=store.thread(fixture.get("web_notice").getAsString(),0).entry();
CommunitySmoke.check(webNotice.task().materials().getFirst().count()==128 && webNotice.task().rewards().getFirst().item().equals("minecraft:diamond"),"Website task descriptors read in Java");
var thread=store.thread(fixture.get("article").getAsString(),0);
CommunitySmoke.check(thread.entry().revision()==2 && thread.entry().body().equals("Modifié depuis le site 🌳"),"Website edit visible in Java");
CommunityPhoto.validate(store.thread(fixture.get("web_article").getAsString(),0).entry().photo());
CommunitySmoke.check(!thread.entry().photo().isEmpty(),"Photo preserved by website edit");
CommunitySmoke.check(thread.replies().entries().size()==1 && thread.replies().entries().getFirst().body().equals("Réponse depuis le site"),"Website reply visible in Java");
CommunitySmoke.refuse("conflict",()->store.change(actor,new Change("edit",thread.entry().id(),"","article","info","Depuis Minecraft","Ancien brouillon",1)));
} else throw new IllegalArgumentException(phase);
@@ -8,11 +8,70 @@ public final class CommunitySmoke {
static Actor owner=new Actor(UUID.randomUUID().toString(),"Auteur",false);
static Actor other=new Actor(UUID.randomUUID().toString(),"Voisin",false);
static Actor moderator=new Actor(UUID.randomUUID().toString(),"Admin",true);
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0); }
public static String photo() {
try {var image=new java.awt.image.BufferedImage(960,540,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);for(int y=0;y<540;y++)for(int x=0;x<960;x++)image.setRGB(x,y,random.nextInt());
return CommunityPhoto.encode(image,960,540);
} catch(Exception e) {throw new AssertionError(e);}
}
static final String PHOTO=photo();
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0,kind.equals("article")?PHOTO:null); }
static Change action(String action,Entry e) { return new Change(action,e.id(),e.parentId(),e.kind(),e.category(),e.title(),e.body(),e.revision()); }
static void check(boolean ok,String why) { if(!ok)throw new AssertionError(why); }
interface Work { void run() throws Exception; }
static void refuse(String code,Work work) throws Exception { try { work.run(); throw new AssertionError("Expected "+code); } catch(Refused e) { check(e.code.equals(code),"Expected "+code+", got "+e.code); } }
static Actor tasks(CommunityStore store) throws Exception {
var task=new CommunityTask(new CommunityTask.Location("minecraft:overworld",12,64,-4),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"single","items",List.of(new CommunityTask.Material("minecraft:diamond",2)),"");
var post=store.change(owner,new Change("create",UUID.randomUUID().toString(),"","notice","need","Le pont","Du bois",0,null,task));
refuse("own_notice",()->store.follow(owner,post.id(),true));
var third=new Actor(UUID.randomUUID().toString(),"Troisième",false);
try(var pool=java.util.concurrent.Executors.newFixedThreadPool(2)) {
var start=new java.util.concurrent.CountDownLatch(1);
var futures=new ArrayList<java.util.concurrent.Future<Boolean>>();
for(var actor:List.of(other,third))futures.add(pool.submit(()->{start.await();try{store.follow(actor,post.id(),true);return true;}catch(Refused e){check(e.code.equals("notice_full"),"Only full is expected");return false;}}));
start.countDown();int accepted=0;for(var future:futures)if(future.get())accepted++;
check(accepted==1 && store.members(post.id()).size()==1,"Atomic exclusive follow");
}
var winner=store.members(post.id()).getFirst().playerId().equals(other.id())?other:third;
store.follow(winner,post.id(),true);check(store.following(winner).stream().anyMatch(e->e.id().equals(post.id())&&e.task().equals(task)),"Persistent descriptors in following snapshot");
var closed=store.change(owner,action("close",post));check(store.members(post.id()).size()==1,"Close retains subscriptions");
refuse("closed",()->store.follow(winner==other?third:other,post.id(),true));
store.follow(winner,post.id(),false);check(store.members(post.id()).isEmpty(),"Unfollow closed notice");
var opened=store.change(owner,action("reopen",closed));
var shared=new CommunityTask(task.location(),task.materials(),"shared","none",List.of(),"");
var changed=store.change(owner,new Change("edit",opened.id(),"","notice","event",opened.title(),opened.body(),opened.revision(),null,shared));
store.follow(other,post.id(),true);store.follow(third,post.id(),true);
refuse("notice_full",()->store.change(owner,new Change("edit",changed.id(),"","notice","need",changed.title(),changed.body(),changed.revision(),null,task)));
store.change(owner,action("hide",changed));check(store.members(post.id()).isEmpty(),"Hidden removes subscriptions");
var limited=new Actor(UUID.randomUUID().toString(),"Limited",false);
for(int i=0;i<16;i++){var e=store.change(owner,create(UUID.randomUUID().toString(),"","notice","N"+i,"B"));store.follow(limited,e.id(),true);}
var extra=store.change(owner,create(UUID.randomUUID().toString(),"","notice","Extra","B"));refuse("follow_limit",()->store.follow(limited,extra.id(),true));
refuse("task_invalid",()->new CommunityTask.Material("minecraft:air",1));refuse("task_invalid",()->new CommunityTask.Material("minecraft:oak_log",0));
check(new com.google.gson.GsonBuilder().disableHtmlEscaping().create().toJson(task).length()<8000,"Task fits request envelope");
System.out.println("COMMUNITY158_TASKS_PASS "+store.getClass().getSimpleName());return limited;
}
static void search(CommunityStore store) throws Exception {
var author=new Actor(UUID.randomUUID().toString(),"Chercheur_159",false);
Entry first=null;
for(int i=0;i<13;i++) {
var e=store.change(author,create(UUID.randomUUID().toString(),"","notice","Recherche159 "+i,i==0?"Épingle 50%_exact":"Description159"));
if(i==0)first=e;
}
var pinned=store.change(moderator,action("pin",first));
check(store.list("notice","",0," CHERCHEUR_159 ").entries().getFirst().id().equals(pinned.id()),"Search author and retain pins");
check(store.list("notice","",0,"recherche159").more()&&store.list("notice","",1,"recherche159").entries().size()==3,"Search before pagination");
check(store.list("notice","",0,"50%_exact").entries().size()==1,"Literal SQL wildcard characters");
check(store.list("notice","",0,"ÉPINGLE").entries().size()==1,"Accented case-insensitive body search");
check(store.list("notice","",0,"%").entries().size()==1,"Percent is literal");
check(store.list("article","",0,"Chercheur_159").entries().isEmpty(),"Search stays within selected section");
store.change(moderator,action("hide",pinned));
check(store.list("notice","",0,"50%_exact").entries().isEmpty(),"Hidden search result excluded");
refuse("invalid",()->store.list("notice","",0,"x".repeat(121)));
var official=store.change(moderator,create(UUID.randomUUID().toString(),"","bulletin","Serveur159","Message officiel"));
check(store.list("bulletin","",0,"serveur159").entries().size()==1 && store.thread(official.id(),0).entry().equals(official),"Read server history");
refuse("forbidden",()->store.change(owner,action("hide",official)));
System.out.println("COMMUNITY159_SEARCH_PASS "+store.getClass().getSimpleName());
}
static Entry scenario(CommunityStore store) throws Exception {
var request=create(UUID.randomUUID().toString(),"","notice","Un pont","Du bois et des bras 🌳");
var post=store.change(owner,request);
@@ -37,15 +96,27 @@ public final class CommunitySmoke {
refuse("missing",()->store.change(other,action("edit",reply)));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","§ccommand")));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","x".repeat(4001))));
refuse("photo_required",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0)));
refuse("photo_invalid",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0,"not an image")));
var article=store.change(owner,create(UUID.randomUUID().toString(),"","article","🌳".repeat(120),"Récit"));
check(store.thread(article.id(),0).entry().equals(article),"Unicode text intact");
return article;
check(article.photo().equals(PHOTO),"Assigned photo persisted");
check(store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,PHOTO)).equals(article),"Photo retry is idempotent");
refuse("conflict",()->store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,"changed")));
refuse("photo_invalid",()->CommunityPhoto.validate("A".repeat(CommunityPhoto.MAX_TEXT+1)));
var edited=store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",1));
check(edited.photo().equals(PHOTO),"Edit retains photo");
refuse("photo_required",()->store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,"")));
refuse("forbidden",()->store.change(other,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,PHOTO)));
return edited;
}
public static void main(String[] args) throws Exception {
Path dir=Files.createTempDirectory("sanctuary-community-check-");
String server=UUID.randomUUID().toString();Path file=dir.resolve("community.json");
var store=new FileCommunityStore(file,server);var article=scenario(store);
var follower=tasks(store);search(store);
var reopened=new FileCommunityStore(file,server);
check(reopened.following(follower).equals(store.following(follower)) && reopened.following(follower).size()==16,"Follow state survives restart");
check(reopened.thread(article.id(),0).entry().equals(article),"Durable restart");
Files.writeString(file,"broken");
try { reopened.change(owner,create(UUID.randomUUID().toString(),"","article","A","B"));throw new AssertionError("Overwrite corrupt file"); }catch(java.io.IOException expected){}
@@ -53,7 +124,7 @@ public final class CommunitySmoke {
String url=System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC");
if(url!=null){
var sql=new SqlCommunityStore(url,"root","",server);
var value=scenario(sql);
var value=scenario(sql);tasks(sql);search(sql);
check(new SqlCommunityStore(url,"root","",server).thread(value.id(),0).entry().equals(value),"Database restart");
var isolated=new SqlCommunityStore(url,"root","",UUID.randomUUID().toString());
check(isolated.list("notice","",0).entries().isEmpty(),"Different server isolated");
@@ -72,6 +143,28 @@ public final class CommunitySmoke {
refuse("already_linked",()->sql.link(owner,reassignment));
System.out.println("COMMUNITY154_SQL_PASS");
}
String operation=UUID.randomUUID().toString();
var transfer=new CommunityPhoto.Transfer(operation,2,1000);
transfer.append(operation,0,2,"abc",1000);
refuse("photo_invalid",()->transfer.append(operation,0,2,"abc",1001));
refuse("photo_required",()->transfer.value(operation,1001));
transfer.append(operation,1,2,"def",1002);
check(transfer.value(operation,1003).equals("abcdef"),"Ordered transfer");
refuse("photo_required",()->transfer.value(UUID.randomUUID().toString(),1003));
refuse("photo_required",()->transfer.value(operation,121001));
for(int oldVersion:List.of(1,2)) {
Path legacy=dir.resolve("legacy"+oldVersion+".json");
var root=new com.google.gson.JsonObject();root.addProperty("version",oldVersion);root.addProperty("serverId",server);
var entries=new com.google.gson.JsonArray();var old=new com.google.gson.Gson().toJsonTree(article).getAsJsonObject();old.remove("photo");entries.add(old);root.add("entries",entries);
String before=root.toString();Files.writeString(legacy,before);
var migrated=new FileCommunityStore(legacy,server);
check(migrated.thread(article.id(),0).entry().photo().isEmpty(),"Old text article readable");
refuse("photo_required",()->migrated.change(owner,action("edit",migrated.thread(article.id(),0).entry())));
migrated.change(owner,new Change("edit",article.id(),"","article","info",article.title(),article.body(),article.revision(),PHOTO));
check(Files.readString(dir.resolve("legacy"+oldVersion+".json.v"+oldVersion+".bak")).equals(before),"Exact migration backup");
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
}
System.out.println("COMMUNITY157_PHOTOS_PASS");
System.out.println("COMMUNITY154_FILE_PASS");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.155"
version = "beta.159"
pack-format = "packwiz:1.1.0"
[index]