Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8deba7cfb4 | ||
|
|
c1d2278674 | ||
|
|
bdaad0c940 | ||
|
|
19aa8054a5 | ||
|
|
9791d178ab |
@@ -1,5 +1,38 @@
|
||||
# Sanctuary
|
||||
|
||||
## beta.161 — correctifs de séance et site vivant
|
||||
|
||||
Liste des joueurs sur U, suivi des demandes expliqué, intendance compacte et
|
||||
compteurs réels partagés avec le site. Site public en lecture seule, Gazette
|
||||
en accordéon avec aperçus compacts.
|
||||
[Contrat et vérifications](docs/session-fixes-beta161.md).
|
||||
|
||||
## beta.160 — laboratoire de test en duo
|
||||
|
||||
Shader désactivé sur une configuration neuve, serveur local léger, personnages
|
||||
de test optionnels et séances de captures horodatées.
|
||||
[Mode d’emploi et vérifications](docs/duo-lab-beta160.md) ·
|
||||
[Récap Discord 130–160](docs/discord-beta130-160.md).
|
||||
|
||||
## beta.159 — recherche communautaire et galerie adaptable (pack local)
|
||||
|
||||
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# beta.159 — recherche communautaire et galerie adaptable
|
||||
|
||||
Branche `codex/community-search-gallery-beta159`, socle beta.158.
|
||||
|
||||
## Résultat
|
||||
|
||||
Une barre de recherche filtre les publications par auteur, titre ou texte côté
|
||||
serveur, avant pagination. Le filtre accompagne les pages suivantes, conserve
|
||||
les épingles et se réinitialise en effaçant le champ. Les réponses aux anciennes
|
||||
recherches sont ignorées et le focus de saisie est conservé.
|
||||
|
||||
Les trois onglets Gazette / Tableau / Serveur donnent accès aux publications de
|
||||
chaque rubrique. L’en-tête d’intendance de la pause ouvre aussi son historique.
|
||||
La lecture est publique ; la publication et la gestion des messages du serveur
|
||||
restent réservées au panneau administrateur, même pour un opérateur visitant
|
||||
l’écran public. Les messages du serveur n’acceptent ni réponses ni abonnements.
|
||||
|
||||
L’année figure dans la date de pause et les métadonnées des publications. La
|
||||
galerie utilise l’espace disponible aux différentes échelles de GUI et tailles
|
||||
de fenêtre. Les captures restent paginées par 24 avec défilement. Cliquer une
|
||||
capture ouvre un grand aperçu avec les commandes de retour et d’assignation.
|
||||
La photo assignée reste compacte dans le formulaire et les brouillons sont
|
||||
conservés lors du retour. Aucune modification des PNG originaux.
|
||||
|
||||
Les annonces suivies géolocalisées portent un « ! » sur les deux cartes.
|
||||
L’infobulle fournit titre, auteur, résumé, coordonnées, matériaux et récompense
|
||||
sans ouvrir la discussion ; le clic reste disponible. Un bouton dans l’annonce
|
||||
centre le grand atlas sur sa position, dans la dimension courante. Aucune
|
||||
téléportation ni révélation du terrain : seules les coordonnées publiées sont
|
||||
projetées. Les annonces fermées, masquées, sans lieu ou non suivies ne portent
|
||||
pas de repère. Les longues infobulles sont abrégées pour tenir dans le GUI.
|
||||
|
||||
Le site suit le même contrat de recherche et de lecture. Schéma v3 inchangé,
|
||||
aucune migration de sauvegarde ni de base. Libellés français et anglais.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Stockage fichier et MariaDB : recherche par auteur et corps, casse et accents,
|
||||
filtre avant pagination, épingles conservées, caractères `%` / `_` littéraux,
|
||||
rubrique et serveur isolés, contenus masqués exclus, borne de 120 caractères.
|
||||
- Client natif Minecraft 26.3 / Java 25 / Vulkan : galerie et aperçu en FR/EN,
|
||||
GUI 2/3/4 et fenêtres 1280×960 / 1920×1080 ; retour au brouillon, assignation,
|
||||
publication, listes défilantes et chargement des pages suivantes.
|
||||
- Recherche en jeu au-delà de la première page, filtre par joueur, effacement,
|
||||
conservation du focus et rejet d’une réponse correspondant à l’ancien filtre.
|
||||
- Lecture des messages serveur par un joueur ordinaire, aucun contrôle d’écriture
|
||||
dans les pages publiques, y compris pour l’opérateur ; administration inchangée.
|
||||
- Carte : projection dans la dimension correspondante, titre/auteur/résumé,
|
||||
matériaux et récompense au survol ; ouverture de la demande par clic sur les
|
||||
deux cartes ; retrait immédiat du repère après désabonnement.
|
||||
- Date avec année en FR/EN, GUI 2/3/4, sans chevauchement de l’intendance.
|
||||
- Site : 24 tests / 157 assertions sur SQLite puis MariaDB de développement,
|
||||
formatage Pint réussi. Commit local `e70e78d`, branche `codex/community-search`.
|
||||
|
||||
La commande complète `./gradlew :sanctuary:runClientGameTest check build assemblePack`
|
||||
avec les propriétés client Cards158, QuickTests et Vulkan a exécuté 252 GameTests :
|
||||
229 réussites et les mêmes 23 échecs que beta.158. Aucun test serveur supprimé
|
||||
ou modifié. La comparaison est conservée dans `build/search159-server-failures.json`
|
||||
et le journal dans `build/beta159-check-build.log`.
|
||||
|
||||
Les contrôles restants, la compilation et `assemblePack` passent avec la suite
|
||||
serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 133 tâches, journal
|
||||
`build/beta159-release.log`. Le parcours client final passe en mode MariaDB,
|
||||
puis en mode fichier (`build/beta159-file-client.log`). Les repères ont été
|
||||
vérifiés avec et sans terrain d’atlas débloqué ; les infobulles restent dans la
|
||||
fenêtre aux GUI 2/3/4. Captures représentatives inspectées :
|
||||
`build/search159-file-screens/` et `build/search159-database-screens/`.
|
||||
|
||||
L’export packwiz est vérifié : intégrité ZIP, version beta.159, JAR embarqué
|
||||
identique au JAR de livraison. Les 29 ressources de shaders sont identiques à
|
||||
beta.158, qui conserve le socle beta.151. Les anciens JAR et MRpack beta.154 à
|
||||
beta.158 restent présents. Aucun monde personnel, déploiement de serveur, canal
|
||||
packwiz ou instance Prism n’est modifié.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.159.jar` — SHA-256 `c30d57a5f37e0a5c9dbb387ca831ce9e5c15a1dfe4cb55af0af20a376e862576`.
|
||||
|
||||
- `build/Sanctuary-beta.159.mrpack` — SHA-256 `868932d88bc241bfb526a7c5e4054009e247658bd0784b7141d82017edcc2823`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Récap Discord — beta.130 à beta.160
|
||||
|
||||
Brouillon à relire avant publication. Les captures sont des scènes de test,
|
||||
pas des photos d'un serveur public. Aucune annonce envoyée automatiquement.
|
||||
|
||||
## Message 1 — l'image et la lumière
|
||||
|
||||
**Sanctuary — retour sur les beta.130 à 160**
|
||||
|
||||
On a d'abord beaucoup travaillé l'ambiance : bloom, minerais lumineux, rebonds
|
||||
de couleur, relief des matériaux, reflets, rayons du soleil et lumières portées.
|
||||
L'eau a reçu ses propres réglages, puis plusieurs passes de correction ont
|
||||
amélioré les transitions, les distances et les matières.
|
||||
|
||||
La version du shader conservée est celle du socle beta.151. Elle reste gourmande,
|
||||
mais on garde ce travail ! En beta.160, le shader devient **désactivé par défaut
|
||||
sur une nouvelle configuration**, et peut être réactivé dans les options.
|
||||
|
||||
Images : `01-minerais-bloom-beta130.png`, `02-eau-beta144.png`.
|
||||
|
||||
## Message 2 — la vie du serveur
|
||||
|
||||
Le chantier suivant rapproche Minecraft et le site Sanctuary : **Gazette,
|
||||
tableau d'affichage et intendance** partagent un contrat de publications.
|
||||
Le mod fonctionne avec des fichiers par défaut ; une base MariaDB permet de
|
||||
partager les données avec le site.
|
||||
|
||||
Le menu pause a été réorganisé autour des systèmes Sanctuary, de la carte et
|
||||
des publications. La Gazette accepte les articles avec une capture obligatoire,
|
||||
les réponses, les épingles et la recherche par joueur ou contenu.
|
||||
|
||||
Le tableau distingue les types d'annonces par couleur. Les demandes peuvent
|
||||
indiquer un lieu, des matériaux, une récompense et des participants. Ce sont
|
||||
des descriptions pour organiser les échanges entre joueurs : aucun coffre de
|
||||
dépôt ni paiement automatique. Une demande suivie apparaît avec un **! sur la
|
||||
carte**, et son résumé se lit au survol.
|
||||
|
||||
Images : `03-menu-et-quete-beta159.png`, `04-materiaux-beta158.png`.
|
||||
|
||||
## Message 3 — tester ensemble
|
||||
|
||||
Avec la beta.160, on prépare nos séances de test en binôme sur Mac : un petit
|
||||
serveur local, un seul Minecraft à l'écran et des personnages de laboratoire.
|
||||
Le but : vérifier les échanges côté technique pendant que les vrais essais en
|
||||
jeu nous montrent ce qu'il faut simplifier dans l'interface.
|
||||
|
||||
Des scènes courtes et des captures horodatées permettront de reprendre les
|
||||
moments où l'on hésite, cherche un bouton ou perd le fil. Le prochain chantier
|
||||
est l'ergonomie, dans le jeu comme sur le site.
|
||||
|
||||
## Sources et pièces jointes
|
||||
|
||||
Les images originales et leurs empreintes sont rassemblées dans
|
||||
`build/discord-beta130-160/` (ignoré par Git), avec `images.json` pour la provenance.
|
||||
Les captures anciennes illustrent leur version d'origine, sans revendiquer une
|
||||
nouvelle validation graphique. Les sondages restent une idée, pas une fonction livrée.
|
||||
|
||||
Références : `shader-beta130.md` à `nether-pbr-beta151.md`,
|
||||
`community-beta154.md`, `pause-redesign-beta155.md`, `gazette-photos-beta157.md`,
|
||||
`community-cards-beta158.md`, `community-search-beta159.md`, `duo-lab-beta160.md`.
|
||||
@@ -0,0 +1,155 @@
|
||||
# DUO-160 — laboratoire Mac et tests en binôme
|
||||
|
||||
Branche `codex/duo-lab-beta160`, base `beta.159`. Minecraft 26.3, Java 25,
|
||||
Fabric 0.19.5 ; mêmes dépendances. Validation graphique Vulkan uniquement.
|
||||
|
||||
## Contrat
|
||||
|
||||
Le shader natif est OFF quand sa préférence est absente. Un choix sauvegardé
|
||||
ON ou OFF reste respecté, ainsi que ses intensités. Aucun shader supprimé :
|
||||
les ressources beta.151 restent identiques. Le client du laboratoire reçoit
|
||||
explicitement `enabled:false`, indépendamment de l'installation personnelle.
|
||||
|
||||
Tout le laboratoire réside dans `build/duo/`, ignoré par Git. Le monde plat
|
||||
`duo-flat-160`, graine 160, sert aux interfaces et aux échanges ; il ne valide
|
||||
pas le terrain Sanctuary. Aucun monde personnel ouvert ou modifié.
|
||||
Le module `sanctuary-test` fournit les personnages uniquement avec
|
||||
`-Dsanctuary.duo=true`. Il ne fait pas partie du pack normal.
|
||||
|
||||
## Préparer et lancer
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
|
||||
./gradlew :sanctuary-test:exportDuoLaunch --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G
|
||||
python3 scripts/duo_lab.py prepare
|
||||
python3 scripts/duo_lab.py server
|
||||
# Dans un autre terminal :
|
||||
python3 scripts/duo_lab.py client
|
||||
```
|
||||
|
||||
L'export permet ensuite de lancer Java directement, sans conserver un processus
|
||||
Gradle pendant la séance. Préparer à nouveau ne remplace aucun fichier existant.
|
||||
Le client rejoint automatiquement `127.0.0.1:25575`. Au premier accès,
|
||||
choisir la couleur et le familier dans HELLO_WORLD puis entrer dans Sanctuary.
|
||||
Depuis la console serveur : `op KokaLab` donne les commandes au client de test.
|
||||
|
||||
Le serveur écoute seulement sur loopback et utilise des identités hors ligne
|
||||
pour ce laboratoire. Ce profil ne doit pas être exposé sur le réseau. La liaison
|
||||
authentifiée d'un compte web exige toujours un vrai serveur en mode en ligne ;
|
||||
elle n'est pas contournée par les outils du laboratoire.
|
||||
|
||||
Réglages de départ : serveur 256–768 Mio de heap, vue 4 chunks, simulation 3 ;
|
||||
client 512–2048 Mio, vue 6, simulation 4, 30 FPS, fenêtre 1280×720. La mémoire
|
||||
native, graphique et macOS s'ajoute au heap. Aucun engagement de tenir dans
|
||||
une consommation totale de 2,75 Gio. Ne pas exécuter la compilation en même
|
||||
temps qu'une séance. Augmenter seulement après mesure :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py server --memory 1024
|
||||
python3 scripts/duo_lab.py client --memory 2560
|
||||
```
|
||||
|
||||
Le mode fichier reste le défaut. Pour une préparation neuve avec le site local :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py prepare --storage database --server-id UUID_DU_SERVEUR \
|
||||
--jdbc jdbc:mariadb://127.0.0.1:33077/BASE_DE_TEST
|
||||
SANCTUARY_COMMUNITY_DB_PASSWORD='' python3 scripts/duo_lab.py server
|
||||
```
|
||||
|
||||
La base doit déjà avoir le schéma communautaire v3. Le site doit utiliser le
|
||||
même identifiant de serveur. Aucun schéma ni sauvegarde n'est migré ici.
|
||||
|
||||
## Personnages et séances
|
||||
|
||||
```text
|
||||
/duo spawn Alice
|
||||
/duo spawn Bob
|
||||
/duo list
|
||||
/duo remove Alice
|
||||
/duo trace start
|
||||
/duo trace stop
|
||||
```
|
||||
|
||||
Les personnages portent le préfixe `Lab_`, sont créatifs et apparaissent près
|
||||
de la source de commande. Limite de quatre, noms uniques de 1–12 caractères
|
||||
ASCII alphanumériques ou `_`. Ce sont des ServerPlayer sans transport réseau
|
||||
ni rendu, pas des IA et pas des clients réseau supplémentaires. On peut les
|
||||
cibler avec les commandes natives, par exemple `tp Lab_Bob ~2 ~ ~`.
|
||||
Ils ne valident pas seuls le protocole d'un deuxième client réel.
|
||||
|
||||
La trace consigne noms de test, dimension, positions et angles toutes les deux
|
||||
secondes dans `build/duo/server/duo-sessions/`. Activation explicite, arrêt au
|
||||
bout de cinq minutes maximum ou à la fermeture du serveur. Pas de frappe clavier,
|
||||
mot de passe, navigateur ou audio enregistré.
|
||||
|
||||
Pour la relecture visuelle, identifier la fenêtre Minecraft puis lancer :
|
||||
|
||||
```sh
|
||||
python3 scripts/duo_lab.py windows
|
||||
python3 scripts/duo_lab.py record --window ID_FENETRE_MINECRAFT --seconds 120
|
||||
```
|
||||
|
||||
Une séquence PNG horodatée toutes les deux secondes, un index HTML et une fiche
|
||||
de notes sont écrits dans `build/duo/sessions/`. Ctrl-C termine la capture ; limite
|
||||
de cinq minutes. macOS peut demander l'autorisation de capture. Cette version
|
||||
ne produit pas une vidéo continue et peut manquer une interaction très courte.
|
||||
On ne démarre une capture qu'au début d'une scène annoncée.
|
||||
|
||||
## Trois premières scènes
|
||||
|
||||
1. Ouvrir la Gazette, retrouver un auteur, lire un article et répondre. Vérifier
|
||||
que l'autre interface retrouve la réponse, puis noter les hésitations.
|
||||
2. Créer une demande avec lieu et matériaux, la suivre, lire son infobulle sur
|
||||
la carte, ouvrir la discussion puis arrêter le suivi.
|
||||
3. Afficher deux figurants, tester les interactions de proximité et comparer
|
||||
la fluidité à un seul joueur. Garder un véritable second client pour une
|
||||
vérification réseau ultérieure si nécessaire.
|
||||
|
||||
Le récap Discord se trouve dans `discord-beta130-160.md` ; les originaux des
|
||||
quatre illustrations sont copiés avec provenance dans `build/discord-beta130-160/`.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- `./gradlew check build assemblePack` : 252 GameTests, 229 réussites et les
|
||||
mêmes 23 échecs que beta.159. Aucun échec ajouté ; comparaison dans
|
||||
`build/duo160-server-failures.json`, log `build/beta160-check-build.log`.
|
||||
- Contrôles restants, compilation, pack et client natif Vulkan réussis avec la
|
||||
suite serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 137 tâches,
|
||||
`build/beta160-release.log`. Le test `ShaderDefaults160ClientChecks` vérifie
|
||||
préférence absente, objet vide, ON explicite, OFF enregistré et réactivation,
|
||||
avec intensité personnelle conservée. Marqueur `SHADER160_DEFAULTS_PASS`.
|
||||
- Module de test reconstruit après ajustement du transport sans réseau : build
|
||||
réussi. L'export de lancement inclut les arguments fournis par Loom et élimine
|
||||
son argfile imbriqué, que Java ne peut pas réinterpréter depuis un autre argfile.
|
||||
- Serveur réel à 768 Mio : démarrage sur loopback, monde plat neuf ; création de
|
||||
quatre acteurs, doublon et cinquième refusés, retrait et acteur absent vérifiés.
|
||||
Le journal produit 56 observations JSON valides puis s'arrête sur commande.
|
||||
- Client réel séparé, Vulkan, 2048 Mio, shader OFF : connexion jusqu'à l'écran
|
||||
HELLO_WORLD vérifiée visuellement. La création du profil et le parcours en jeu
|
||||
avec le joueur restent à faire ensemble ; aucune session ergonomique humaine
|
||||
terminée ni validation d'un deuxième client réseau revendiquée.
|
||||
- Mesure ponctuelle avant l'entrée du joueur : serveur ~313 Mio de heap utilisé,
|
||||
client au menu ~289 Mio. Ce ne sont ni des pics ni une mesure de RAM système
|
||||
totale. Les figurants ne possèdent ni sockets ni fenêtre graphique.
|
||||
- Capture ciblée macOS essayée sur quatre secondes : séquence PNG, manifeste,
|
||||
index HTML et fiche de notes créés. Aucun enregistrement continu ou micro.
|
||||
- Export MRpack : intégrité ZIP, version et JAR embarqué vérifiés. Les 29 ressources
|
||||
de shaders sont inchangées depuis beta.159. JAR et packs beta.154–159 conservés.
|
||||
- Skill personnel `sanctuary-session-notes` installé et validé séparément dans
|
||||
`~/.codex/skills/` pour classer les dictées en Markdown. Il ne change pas le
|
||||
modèle du tour courant ; Luna/low peut être choisi dans une tâche dédiée.
|
||||
|
||||
Le serveur local utilise la base de développement du site, schéma v3 déjà présent,
|
||||
sous le même scope. Aucune validation de liaison de comptes hors ligne : cette
|
||||
opération reste réservée au mode authentifié. Aucun déploiement public, canal
|
||||
packwiz, serveur personnel ou instance Prism modifié.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
- `mods/sanctuary/build/libs/sanctuary-beta.160.jar` — SHA-256
|
||||
`4c19e620e989050488c9a96aaddaf897a74115a9fb4c5099e5518ae32db5b2e8`.
|
||||
- `build/Sanctuary-beta.160.mrpack` — SHA-256
|
||||
`272e3460f35c8d9a4de689d30e4d952c3121923ba5fe30a23c83b3e76bd23bac`.
|
||||
- `build/duo/Jouer.command` et `Serveur.command` : raccourcis locaux de séance.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# beta.157 — photo obligatoire et galerie de captures
|
||||
|
||||
## Contrat de migration communautaire v2
|
||||
|
||||
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
|
||||
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
|
||||
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
|
||||
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
|
||||
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
|
||||
|
||||
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
|
||||
leur prochaine modification exige une photo. Avis, intendance et réponses restent
|
||||
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
|
||||
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
|
||||
référence à un chemin local, d'URL distante ou de média orphelin persistant.
|
||||
|
||||
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
|
||||
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
|
||||
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
|
||||
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
|
||||
de restaurer la copie v1 et perd les changements communautaires postérieurs.
|
||||
Ne pas restaurer pendant que le serveur tourne.
|
||||
|
||||
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
|
||||
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
|
||||
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
|
||||
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
|
||||
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
|
||||
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
|
||||
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
|
||||
par la préparation de cette livraison.
|
||||
|
||||
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
|
||||
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
|
||||
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
|
||||
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
|
||||
seul le détail d'un article les contient. Le plafond des réponses
|
||||
passe à 262 144 caractères pour une photo et une discussion complète ; les
|
||||
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
|
||||
anciens clients ne peuvent plus créer d'article sans photo.
|
||||
|
||||
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
|
||||
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
|
||||
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
|
||||
Les captures illisibles restent visibles mais désactivées. Limites sources :
|
||||
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
|
||||
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
|
||||
du dossier `screenshots` de son instance.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
|
||||
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
|
||||
désactivé sans photo, envoi fragmenté, photo visible et relecture après
|
||||
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
|
||||
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
|
||||
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
|
||||
copie de migration exacte, fragments hors ordre/incomplets/expirés.
|
||||
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
|
||||
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
|
||||
anciennes publications et conservation après modification.
|
||||
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
|
||||
modification et réponse Web relues en Java, article/photo du site décodés par
|
||||
Java. Migration Laravel et alternative SQL testées sur bases de développement.
|
||||
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
|
||||
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
|
||||
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
|
||||
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
|
||||
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
|
||||
indépendants, pagination, article et avis longs, réponse, options et reprise :
|
||||
réussie avec les articles munis d'une photo.
|
||||
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
|
||||
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
|
||||
client natif du menu pause), 2 min 56 s.
|
||||
|
||||
Les captures d'essai sont des mires générées dans une instance de développement,
|
||||
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
|
||||
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
|
||||
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
|
||||
|
||||
|
||||
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
|
||||
`9626e95`, sans push ni publication. Logs de développement :
|
||||
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
|
||||
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
|
||||
`build/photos157-final-build.log`.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
|
||||
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
|
||||
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
|
||||
Reçu complet : `build/photos157-validation.json`.
|
||||
|
||||
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
|
||||
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# beta.161 — correctifs de la séance du 23 septembre
|
||||
|
||||
Ticket : R002–R009 du carnet de séance. Branches `codex/session-fixes-beta161`.
|
||||
|
||||
## Contrat de données et migration additive
|
||||
|
||||
Aucun monde ni chunk n’est régénéré, aucun format existant n’est modifié.
|
||||
Le nouveau fichier `data/sanctuary-server-metrics.json` version 1 conserve le temps
|
||||
réel cumulé depuis l’activation du compteur, les morts connues par UUID et les
|
||||
UUID des figurants exclus. Au premier démarrage, les morts sont importées des
|
||||
statistiques Minecraft déjà présentes ; le temps antérieur n’est pas inventé.
|
||||
Les valeurs vivantes sont relevées côté serveur, sauvegardées toutes les 30 secondes
|
||||
et à l’arrêt. Les arrêts ne comptent pas dans la durée. Un crash peut perdre au
|
||||
plus la période depuis le dernier relevé enregistré. Un fichier corrompu ou
|
||||
modifié extérieurement arrête la collecte sans le remplacer.
|
||||
|
||||
En mode base, appliquer la migration Web additive `create_sanctuary_server_metrics_table`
|
||||
avant de lancer le mod. Elle crée une table indépendante, une ligne par server_id ;
|
||||
le schéma communautaire v3 et ses données restent inchangés. Le serveur publie un
|
||||
instantané toutes les 30 secondes et un état arrêté à l’arrêt propre. En cas de
|
||||
panne SQL le compteur local continue ; le site marque la dernière mesure périmée
|
||||
après 90 secondes. Une table absente ne bloque pas les publications communautaires.
|
||||
La suppression de cette nouvelle table au rollback perd uniquement les instantanés,
|
||||
recréés depuis le fichier local à la prochaine publication. Ne pas supprimer le fichier
|
||||
local pour revenir en arrière ; les anciennes versions l’ignorent.
|
||||
Un seul serveur actif par server_id, comme pour les publications. Les morts de tous
|
||||
les joueurs ayant des statistiques sont cumulées, y compris les joueurs déconnectés.
|
||||
Les figurants du laboratoire sont exclus des morts et des joueurs en ligne.
|
||||
|
||||
## Résultat livré
|
||||
|
||||
Liste des joueurs sur U (migration unique de Tab ; autres choix conservés), suivi
|
||||
et place unique expliqués dans les demandes. Site : retour au dessin d’origine,
|
||||
intendance limitée au titre, page officielle avec retour, pas de navbar de rubriques
|
||||
ni de sélecteur de langue ni d’incitation à se connecter dans les publications.
|
||||
Gazette en accordéon exclusif : le dernier article visible est ouvert au chargement,
|
||||
ouvrir un autre referme le précédent. Photo entière à gauche, extrait court à droite,
|
||||
visage/pseudo/date et lien de lecture alignés en bas. Calendrier jour/mois encadré,
|
||||
message officiel à droite, sans heure ; année dans le footer.
|
||||
Les pages publiques du site sont en lecture seule, y compris leurs anciennes
|
||||
routes d’écriture pour les comptes connectés et le staff. L’administration
|
||||
Intendance réservée au staff reste disponible. Les joueurs publient, répondent
|
||||
et suivent les demandes dans Minecraft. Compteurs réels et état périmé explicite.
|
||||
|
||||
## Vérifications
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `./gradlew check build assemblePack` | 252 GameTests : 229 réussis, les mêmes 23 échecs que beta.160, aucun nouvel échec dans cette passe. Comparaison dans `build/session161-server-failures.json`. |
|
||||
| Contrôles JVM et persistance fichier/SQL | Réussis, dont `SERVER_METRICS161_PASS` et `COMMUNITY154_SQL_PASS` dans `build/beta161-release.log`. |
|
||||
| Démarrage et raccourci U | `SESSION161_KEYS_PASS` : défaut U, migration unique depuis Tab, choix personnalisé conservé. |
|
||||
| Parcours natif communautaire | `SEARCH159_CLIENT_PASS mode=file` : photos/galerie, FR/EN, GUI 2/3/4, recherche, bulletin en lecture seule, suivi et infobulle du repère. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`. |
|
||||
| Web SQLite et MariaDB isolée | 29 tests, 252 assertions réussies ; lecture seule visiteurs/joueurs/staff, contrats de persistance, statistiques absentes/périmées/arrêtées. Pint et `git diff --check` réussis. |
|
||||
| Navigateur | Accordéon exclusif, lien vers l’article, calendrier sans heure, visage/auteur/date et lecture en bas ; vérification desktop et 390 × 844. |
|
||||
| Assemblage final | `./gradlew build assemblePack :sanctuary-test:exportDuoLaunch -x :sanctuary:check` réussi, après les vérifications ci-dessus. Exclusion ponctuelle des tests connus en échec, pas de suppression d’assertion. |
|
||||
| Laboratoire réel | Monde existant `duo-flat-160` relancé, serveur 768 MiB/client 2048 MiB, shaders désactivés. KokaLab non OP, 26 niveaux conservés. Deux figurants, compteur SQL et Web : 1 humain. Journal de durée persisté et exclusions vérifiées. |
|
||||
|
||||
Le premier démarrage de test a révélé une mauvaise déclaration du nouveau mixin :
|
||||
`PlayerListKeyMixin` au lieu de `client.PlayerListKeyMixin`. Elle est corrigée ;
|
||||
la classe et sa déclaration sont présentes dans le JAR final. Le test natif a
|
||||
ensuite réussi. L’assemblage avait redéclenché une seconde passe de GameTests
|
||||
serveur ; cette répétition a été interrompue, la première passe complète faisant
|
||||
foi. Logs distincts : `beta161-check-build.log`, `beta161-release.log`,
|
||||
`beta161-client.log`, `beta161-assembly.log`, sous `build/`.
|
||||
|
||||
Livraison locale uniquement : `mods/sanctuary/build/libs/sanctuary-beta.161.jar`
|
||||
et pack assemblé dans `build/packwiz`. SHA-256 du JAR :
|
||||
`c6e0424bf091c41827730bc260dad3b95b7c949fb0dcc41811f0ae3d94bce79c`.
|
||||
Les 29 ressources shader sont identiques à beta.160. Les anciens JAR beta.154–160
|
||||
sont conservés. Aucun canal public ni instance Prism personnelle n’a été modifié.
|
||||
Le téléchargement public du site reste l’ancien lien beta.151, hors de cette
|
||||
livraison locale. Les 23 échecs préexistants restent ouverts.
|
||||
|
||||
Le compteur de jours débute à l’activation de cette version : le fonctionnement
|
||||
antérieur n’était pas mesuré. Le site reçoit les nouveaux compteurs au rechargement
|
||||
de la page. La mesure d’une mort réelle supplémentaire n’a pas été provoquée sur
|
||||
le personnage de la séance ; l’import et l’incrément sont couverts par le test du
|
||||
journal et les tests de lecture Web.
|
||||
+2
-2
@@ -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.156
|
||||
pack_version=beta.156
|
||||
mod_version=beta.161
|
||||
pack_version=beta.161
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -16,5 +16,39 @@ processResources {
|
||||
filesMatching('fabric.mod.json') { expand(values) }
|
||||
}
|
||||
loom { runs { client { runDir 'run/quick-test' } } }
|
||||
loom {
|
||||
runs {
|
||||
duoServer {
|
||||
server()
|
||||
name 'Sanctuary duo lab'
|
||||
runDir '../../build/duo/server'
|
||||
vmArg '-Xms256m'
|
||||
vmArg '-Xmx768m'
|
||||
vmArg '-Dsanctuary.duo=true'
|
||||
}
|
||||
duoClient {
|
||||
client()
|
||||
name 'Sanctuary duo client'
|
||||
runDir '../../build/duo/client'
|
||||
vmArg '-Xms512m'
|
||||
vmArg '-Xmx2G'
|
||||
programArgs '--graphicsBackend', 'vulkan', '--username', 'KokaLab', '--width', '1280', '--height', '720'
|
||||
}
|
||||
}
|
||||
}
|
||||
// Export the Loom launcher so play sessions do not retain a Gradle JVM.
|
||||
tasks.register('exportDuoLaunch') {
|
||||
dependsOn tasks.named('runDuoServer').get().taskDependencies.getDependencies(tasks.named('runDuoServer').get())
|
||||
dependsOn tasks.named('runDuoClient').get().taskDependencies.getDependencies(tasks.named('runDuoClient').get())
|
||||
doLast {
|
||||
['server': 'runDuoServer', 'client': 'runDuoClient'].each { kind, taskName ->
|
||||
def launch = tasks.named(taskName).get()
|
||||
def destination = rootProject.layout.buildDirectory.file("duo/${kind}.args").get().asFile
|
||||
destination.parentFile.mkdirs()
|
||||
def arguments = launch.allJvmArgs.findAll { !it.toString().startsWith('@') } + ['-classpath', launch.classpath.asPath, launch.mainClass.get()] + launch.args + launch.argumentProviders.collectMany { it.asArguments().toList() }
|
||||
destination.text = arguments.collect { '"' + it.toString().replace('\\', '\\\\').replace('"', '\\"') + '"' }.join('\n') + '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named('test') { failOnNoDiscoveredTests=false }
|
||||
tasks.named('jar') { from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary_test' } } }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package fr.koka.sanctuarytest;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import fr.koka.sanctuary.operator.OperatorService;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ClientInformation;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.CommonListenerCookie;
|
||||
import net.minecraft.world.level.GameType;
|
||||
|
||||
/** Optional development module only. No sockets, renderer or additional JVM per actor. */
|
||||
final class DuoLab {
|
||||
private static final Map<String, ServerPlayer> ACTORS = new LinkedHashMap<>();
|
||||
private static java.io.BufferedWriter trace;
|
||||
private static int ticks;
|
||||
private static long traceStarted;
|
||||
private DuoLab() { }
|
||||
|
||||
static void register() {
|
||||
// An explicit JVM opt-in keeps the ordinary quick-test pack unchanged.
|
||||
if (!Boolean.getBoolean("sanctuary.duo")) return;
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) ->
|
||||
dispatcher.register(Commands.literal("duo").requires(OperatorService::canCommand)
|
||||
.then(Commands.literal("trace")
|
||||
.then(Commands.literal("start").executes(c -> trace(c.getSource(), true)))
|
||||
.then(Commands.literal("stop").executes(c -> trace(c.getSource(), false))))
|
||||
.then(Commands.literal("spawn").then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(c -> spawn(c.getSource(), StringArgumentType.getString(c, "name")))))
|
||||
.then(Commands.literal("remove").then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(c -> remove(c.getSource(), StringArgumentType.getString(c, "name")))))
|
||||
.then(Commands.literal("list").executes(c -> {
|
||||
c.getSource().sendSuccess(() -> Component.translatable("sanctuary_test.duo.list", String.join(", ", ACTORS.keySet())), false);
|
||||
return ACTORS.size();
|
||||
}))));
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||
if (trace != null && System.nanoTime() - traceStarted >= 300_000_000_000L) closeTrace();
|
||||
if (trace == null || ++ticks % 40 != 0) return;
|
||||
try {
|
||||
for (var player : server.getPlayerList().getPlayers()) {
|
||||
var row = new com.google.gson.JsonObject();
|
||||
row.addProperty("time", java.time.Instant.now().toString());
|
||||
row.addProperty("player", player.getGameProfile().name());
|
||||
row.addProperty("actor", ACTORS.containsValue(player));
|
||||
row.addProperty("dimension", player.level().dimension().identifier().toString());
|
||||
row.addProperty("x", player.getX()); row.addProperty("y", player.getY()); row.addProperty("z", player.getZ());
|
||||
row.addProperty("yaw", player.getYRot()); row.addProperty("pitch", player.getXRot());
|
||||
trace.write(row.toString()); trace.newLine();
|
||||
}
|
||||
trace.flush();
|
||||
} catch (java.io.IOException error) {
|
||||
org.slf4j.LoggerFactory.getLogger("sanctuary-duo").error("Trace stopped: cannot write session", error);
|
||||
closeTrace();
|
||||
}
|
||||
});
|
||||
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { ACTORS.clear(); closeTrace(); });
|
||||
}
|
||||
|
||||
private static int trace(CommandSourceStack source, boolean start) {
|
||||
closeTrace();
|
||||
if (start) try {
|
||||
var folder = java.nio.file.Path.of("duo-sessions");
|
||||
java.nio.file.Files.createDirectories(folder);
|
||||
var path = folder.resolve("positions-" + System.currentTimeMillis() + ".jsonl");
|
||||
trace = java.nio.file.Files.newBufferedWriter(path, StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE_NEW);
|
||||
traceStarted = System.nanoTime(); ticks = 0;
|
||||
} catch (java.io.IOException error) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.trace_failed")); return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.trace_" + (start ? "started" : "stopped")), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void closeTrace() {
|
||||
if (trace != null) try { trace.close(); } catch (java.io.IOException ignored) { }
|
||||
trace = null;
|
||||
}
|
||||
|
||||
private static int spawn(CommandSourceStack source, String name) {
|
||||
if (!name.matches("[A-Za-z0-9_]{1,12}") || ACTORS.size() >= 4
|
||||
|| source.getServer().getPlayerList().getPlayerByName("Lab_" + name) != null) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.invalid"));
|
||||
return 0;
|
||||
}
|
||||
String fullName = "Lab_" + name;
|
||||
var profile = new GameProfile(UUID.nameUUIDFromBytes(("SanctuaryDuo:" + fullName).getBytes(StandardCharsets.UTF_8)), fullName);
|
||||
var player = new ServerPlayer(source.getServer(), source.getLevel(), profile, ClientInformation.createDefault());
|
||||
source.getServer().getPlayerList().placeNewPlayer(new SinkConnection(), player, CommonListenerCookie.createInitial(profile, false));
|
||||
var position = source.getPosition();
|
||||
player.teleportTo(source.getLevel(), position.x + 2, position.y, position.z, java.util.Set.of(), 0, 0, false);
|
||||
player.setGameMode(GameType.CREATIVE);
|
||||
ACTORS.put(name, player);
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.spawned", fullName), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int remove(CommandSourceStack source, String name) {
|
||||
var player = ACTORS.remove(name);
|
||||
if (player == null) {
|
||||
source.sendFailure(Component.translatable("sanctuary_test.duo.missing", name));
|
||||
return 0;
|
||||
}
|
||||
source.getServer().getPlayerList().remove(player);
|
||||
source.sendSuccess(() -> Component.translatable("sanctuary_test.duo.removed", name), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static final class SinkConnection extends Connection {
|
||||
private net.minecraft.network.PacketListener listener;
|
||||
SinkConnection() { super(PacketFlow.SERVERBOUND); }
|
||||
@Override public <T extends net.minecraft.network.PacketListener> void setupInboundProtocol(net.minecraft.network.ProtocolInfo<T> protocol, T listener) { this.listener = listener; }
|
||||
@Override public net.minecraft.network.PacketListener getPacketListener() { return listener; }
|
||||
@Override public void flushChannel() { }
|
||||
@Override public void send(Packet<?> packet) { }
|
||||
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener) { }
|
||||
@Override public void send(Packet<?> packet, io.netty.channel.ChannelFutureListener listener, boolean flush) { }
|
||||
@Override public boolean isConnected() { return true; }
|
||||
@Override public boolean isConnecting() { return false; }
|
||||
@Override public boolean isMemoryConnection() { return true; }
|
||||
@Override public SocketAddress getRemoteAddress() { return new InetSocketAddress("127.0.0.1", 0); }
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public final class QuickTestMod implements ModInitializer {
|
||||
}
|
||||
public void onInitialize(){
|
||||
QuickTestWorld.enable();
|
||||
DuoLab.register();
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
|
||||
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
|
||||
if(ServerConfigurationNetworking.canSend(handler,Mode.TYPE))
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Quick Test"
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Quick Test",
|
||||
"sanctuary_test.duo.list": "Lab actors: %s",
|
||||
"sanctuary_test.duo.invalid": "Name: 1–12 letters/digits/_; up to 4 actors, unique names.",
|
||||
"sanctuary_test.duo.spawned": "%s spawned nearby (test actor).",
|
||||
"sanctuary_test.duo.missing": "Lab actor not found: %s",
|
||||
"sanctuary_test.duo.removed": "Lab actor removed: %s",
|
||||
"sanctuary_test.duo.trace_failed": "Cannot write session log.",
|
||||
"sanctuary_test.duo.trace_started": "Position log started (one sample every 2 seconds).",
|
||||
"sanctuary_test.duo.trace_stopped": "Position log stopped."
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Test rapide"
|
||||
"generator.sanctuary_test.flat": "Sanctuary — Test rapide",
|
||||
"sanctuary_test.duo.list": "Personnages labo : %s",
|
||||
"sanctuary_test.duo.invalid": "Nom : 1–12 lettres/chiffres/_ ; 4 personnages maximum, noms uniques.",
|
||||
"sanctuary_test.duo.spawned": "%s créé à proximité (personnage de test).",
|
||||
"sanctuary_test.duo.missing": "Personnage labo introuvable : %s",
|
||||
"sanctuary_test.duo.removed": "Personnage labo retiré : %s",
|
||||
"sanctuary_test.duo.trace_failed": "Impossible d’écrire le journal de séance.",
|
||||
"sanctuary_test.duo.trace_started": "Journal des positions démarré (une mesure toutes les 2 secondes).",
|
||||
"sanctuary_test.duo.trace_stopped": "Journal des positions arrêté."
|
||||
}
|
||||
|
||||
@@ -128,6 +128,12 @@ tasks.named('processGametestResources') {
|
||||
def sunshaft137Client = providers.gradleProperty('sanctuarySunshaft137ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySunshaft137ClientTests', sunshaft137Client)
|
||||
if (sunshaft137Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Sunshafts137ClientChecks') }
|
||||
def defaults160 = providers.gradleProperty('sanctuaryDefaults160ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('defaults160', defaults160)
|
||||
if (defaults160) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ShaderDefaults160ClientChecks') }
|
||||
def session161 = providers.gradleProperty('sanctuarySession161ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuarySession161ClientTests', session161)
|
||||
if (session161) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Session161ClientChecks') }
|
||||
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
|
||||
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
|
||||
@@ -258,6 +264,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') }
|
||||
@@ -1235,3 +1247,11 @@ tasks.register('community154Interop', JavaExec) {
|
||||
mainClass = 'fr.koka.sanctuary.community.CommunityInterop'
|
||||
args(providers.gradleProperty('communityInteropPhase').getOrElse('seed'))
|
||||
}
|
||||
|
||||
tasks.register('serverMetrics161Smoke', JavaExec) {
|
||||
group = 'verification'
|
||||
dependsOn('testClasses')
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'fr.koka.sanctuary.community.ServerMetricsSmoke'
|
||||
}
|
||||
tasks.named('check') { dependsOn('serverMetrics161Smoke') }
|
||||
|
||||
+2
-1
@@ -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)); }
|
||||
|
||||
+212
@@ -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();}
|
||||
}
|
||||
+2
-1
@@ -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);}
|
||||
});
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import fr.koka.sanctuary.community.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Screenshot selection and article round trip through actual client/server packets. */
|
||||
public final class Photos157ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
var intro=new AtomicBoolean(true);
|
||||
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
|
||||
try {
|
||||
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
|
||||
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
|
||||
} catch(Exception e){throw new AssertionError(e);}
|
||||
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
|
||||
try {
|
||||
Files.createDirectories(folder.get());
|
||||
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
var random=new Random(157);
|
||||
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
|
||||
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
|
||||
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
|
||||
}catch(Exception e){throw new AssertionError(e);}
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
|
||||
Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
|
||||
press(screen,"sanctuary.progression.enter");intro.set(false);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
|
||||
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
|
||||
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{
|
||||
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
CommunityClient.open(new PauseScreen(true),"article","",true);
|
||||
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
|
||||
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
|
||||
press(m.gui.screen(),"sanctuary.community.photo_choose");
|
||||
});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
|
||||
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
|
||||
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
|
||||
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
|
||||
});
|
||||
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
|
||||
c.runOnClient(m->m.gui.screen().onClose());
|
||||
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
|
||||
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
|
||||
c.takeScreenshot("photos157-preview");
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
|
||||
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
|
||||
c.runOnClient(m->check(widgets(m.gui.screen()).anyMatch(CommunityArticle.class::isInstance),"Published photo displayed"));
|
||||
c.takeScreenshot("photos157-article"+(database?"-database":"-file"));
|
||||
world.getServer().runOnServer(s->{try {
|
||||
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
var posts=store.list("article","",0).entries();check(posts.size()==1,"Single publication");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
|
||||
}catch(Exception e){throw new AssertionError(e);}});
|
||||
c.runOnClient(m->m.gui.setScreen(null));
|
||||
System.out.println("PHOTOS157_CLIENT_PASS mode="+(database?"database":"file"));
|
||||
} finally {intro.set(false);}
|
||||
}
|
||||
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import java.nio.file.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
public final class Session161ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
c.runOnClient(m->{
|
||||
var marker=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
var saved=m.options.keyPlayerList.saveString();
|
||||
try {
|
||||
byte[] previous=Files.exists(marker)?Files.readAllBytes(marker):null;
|
||||
try {
|
||||
check(m.options.keyPlayerList.getDefaultKey().getValue()==InputConstants.KEY_U,"U is reset-to-default player list key");
|
||||
Files.deleteIfExists(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U)),"Saved Tab migrates to U");
|
||||
Files.delete(marker);
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8)),"Custom binding retained");
|
||||
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
|
||||
HotbarClient.migratePlayerListKey(m);
|
||||
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB)),"Migration happens once");
|
||||
System.out.println("SESSION161_KEYS_PASS");
|
||||
} finally {
|
||||
if(previous==null)Files.deleteIfExists(marker);else Files.write(marker,previous);
|
||||
m.options.keyPlayerList.setKey(InputConstants.getKey(saved));net.minecraft.client.KeyMapping.resetMapping();m.options.save();
|
||||
}
|
||||
}catch(Exception error){throw new AssertionError(error);}
|
||||
});
|
||||
new Community158ClientChecks().runTest(c);
|
||||
}
|
||||
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
|
||||
/** Exercise the real preference loader in the isolated native client. */
|
||||
public final class ShaderDefaults160ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext context) {
|
||||
context.runOnClient(client -> {
|
||||
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
|
||||
try {
|
||||
byte[] previous = Files.exists(path) ? Files.readAllBytes(path) : null;
|
||||
var cache = VanillaLight.class.getDeclaredField("preferences");
|
||||
cache.setAccessible(true);
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
cache.set(null, null);
|
||||
check(!VanillaLight.enabled(), "Fresh install disables shaders");
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, "{}"); cache.set(null, null);
|
||||
check(!VanillaLight.enabled(), "Missing setting uses OFF");
|
||||
Files.writeString(path, "{\"enabled\":true,\"pbrIntensity\":73}"); cache.set(null, null);
|
||||
check(VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "Explicit ON and tuning retained");
|
||||
VanillaLight.setEnabled(false); cache.set(null, null);
|
||||
check(!VanillaLight.enabled() && VanillaLight.pbrIntensity() == 73, "OFF persists without losing tuning");
|
||||
VanillaLight.setEnabled(true); cache.set(null, null);
|
||||
check(VanillaLight.enabled(), "Manual reactivation persists");
|
||||
System.out.println("SHADER160_DEFAULTS_PASS");
|
||||
} finally {
|
||||
if (previous == null) Files.deleteIfExists(path); else Files.write(path, previous);
|
||||
cache.set(null, null);
|
||||
}
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
context.takeScreenshot("shader160-native-client");
|
||||
}
|
||||
private static void check(boolean value, String message) { if (!value) throw new AssertionError(message); }
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
fr.koka.sanctuary.community.CommunityNetwork.register();
|
||||
fr.koka.sanctuary.community.ServerMetrics.register();
|
||||
fr.koka.sanctuary.building.ColoredBricks.register();
|
||||
fr.koka.sanctuary.gems.Gems.register();
|
||||
fr.koka.sanctuary.sky.VortexClouds.register();
|
||||
|
||||
@@ -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",items("bulletins").isEmpty()?"":str(items("bulletins").get(0).getAsJsonObject(),"id"),false));
|
||||
add.accept(bulletin);
|
||||
String[] sections={"blocks","history","progress","identity"};
|
||||
int optionsTop=screen.height-76;
|
||||
@@ -66,14 +64,17 @@ 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");
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
|
||||
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title"));
|
||||
if(!overviewError.isEmpty())message=text(overviewError);
|
||||
bulletin.setMessage(message);
|
||||
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
|
||||
date.setWidth(Minecraft.getInstance().font.width(date.getMessage()));
|
||||
int left=Math.max(date.getRight()+8,screen.width/5),right=screen.width-Math.max(56,screen.width/5);
|
||||
bulletin.setRectangle(Math.max(24,right-left),32,left,3);
|
||||
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,77 @@ 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");
|
||||
if(str(entry,"status").equals("open"))line(layout,w,text(subscribed?"follow_map_active":"follow_map_help"));
|
||||
if(!subscribed && str(entry,"status").equals("open") && task.audience().equals("single") && count>0)line(layout,w,text("notice_full_help"));
|
||||
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
|
||||
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
|
||||
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
|
||||
rebuildWidgets();
|
||||
})).width(w).build());
|
||||
follow.active=subscribed || (!own(entry)&&str(entry,"status").equals("open")&&(!task.audience().equals("single")||count==0));
|
||||
}
|
||||
private void mutate(String action,JsonObject entry) {
|
||||
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
|
||||
message=result.has("error")?str(result,"error"):"saved";
|
||||
@@ -151,28 +250,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; }
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityPhoto;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
/** Read-only screenshot library, paged and scrollable; no disk access or source-image decoding on the render thread. */
|
||||
public final class CommunityScreenshotsScreen extends BlocodexFrame {
|
||||
private static final int PAGE_SIZE=24;
|
||||
private static final ExecutorService LOADER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary screenshots");t.setDaemon(true);return t;});
|
||||
private record Shot(String name,String photo,BufferedImage thumbnail) { }
|
||||
private record Library(List<Path> paths,List<Shot> shots,String error) { }
|
||||
private final Consumer<String> assign;
|
||||
private final List<CommunityPhotoWidget> textures=new ArrayList<>();
|
||||
private List<Path> paths=List.of();
|
||||
private List<Shot> shots=List.of();
|
||||
private CompletableFuture<Library> loading;
|
||||
private int page;
|
||||
private boolean loaded;
|
||||
private String error="";
|
||||
private Shot selected;
|
||||
public CommunityScreenshotsScreen(Screen parent,Consumer<String> assign) {super(parent);this.assign=assign;}
|
||||
private void load(boolean refresh) {
|
||||
loaded=true;
|
||||
Path folder=minecraft.gameDirectory.toPath().resolve("screenshots");
|
||||
List<Path> known=paths;int offset=page*PAGE_SIZE;
|
||||
loading=CompletableFuture.supplyAsync(()->{
|
||||
try {
|
||||
List<Path> all=known;
|
||||
if(refresh) {
|
||||
if(!Files.isDirectory(folder))return new Library(List.of(),List.of(),"");
|
||||
try(var files=Files.list(folder)) {
|
||||
all=files.filter(p->p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")&&Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))
|
||||
.sorted(Comparator.comparing((Path p)->p.getFileName().toString()).reversed()).limit(4096).toList();
|
||||
}
|
||||
}
|
||||
var found=new ArrayList<Shot>();
|
||||
for(Path file:all.stream().skip(offset).limit(PAGE_SIZE).toList()) {
|
||||
try {
|
||||
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS) || Files.size(file)>32*1024*1024)throw new java.io.IOException("Screenshot size");
|
||||
byte[] bytes;
|
||||
try(var in=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)) {bytes=in.readNBytes(32*1024*1024+1);}
|
||||
if(bytes.length>32*1024*1024)throw new java.io.IOException("Screenshot size");
|
||||
var source=CommunityPhoto.decode(bytes,8192,8192);
|
||||
String photo=CommunityPhoto.encode(source,CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
|
||||
String thumb=CommunityPhoto.encode(source,240,135);
|
||||
found.add(new Shot(file.getFileName().toString(),photo,CommunityPhoto.decode(Base64.getDecoder().decode(thumb),240,135)));
|
||||
} catch(Exception e) {found.add(new Shot(file.getFileName().toString(),"",null));}
|
||||
}
|
||||
return new Library(all,found,"");
|
||||
} catch(Exception e) {return new Library(List.of(),List.of(),"screenshots_error");}
|
||||
},LOADER);
|
||||
}
|
||||
@Override protected void init() {
|
||||
closeTextures();initFrame("history",text("screenshots"));
|
||||
contentWidth=Math.max(240,width-24);contentX=(width-contentWidth)/2;
|
||||
if(!loaded)load(true);
|
||||
if(selected!=null) {preview();return;}
|
||||
int w=contentWidth-24;
|
||||
var controls=LinearLayout.horizontal().spacing(4);
|
||||
controls.addChild(Button.builder(text("previous"),b->{page--;load(false);rebuildWidgets();}).width(72).build()).active=page>0 && loading==null;
|
||||
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
|
||||
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
|
||||
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
|
||||
controls.setPosition(contentX+8,contentTop+Math.max(24,contentBottom-contentTop-28)+8);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
|
||||
var layout=LinearLayout.vertical().spacing(8);
|
||||
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
|
||||
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
|
||||
else {
|
||||
int columns=Math.max(2,w/112),cell=(w-(columns-1)*8)/columns;
|
||||
for(int i=0;i<shots.size();i+=columns) {
|
||||
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
|
||||
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
|
||||
Shot shot=shots.get(j);
|
||||
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
|
||||
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
|
||||
}
|
||||
}
|
||||
}
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void preview() {
|
||||
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
|
||||
int left=(width-w)/2;
|
||||
try {
|
||||
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
|
||||
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
|
||||
textures.add(widget);widget.setPosition(left,contentTop);addRenderableWidget(widget);
|
||||
} catch(Exception e) {error="photo_invalid";selected=null;return;}
|
||||
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(left,contentTop+h+8,(w-6)/2,20).build());
|
||||
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(left+(w+6)/2,contentTop+h+8,(w-6)/2,20).build());
|
||||
}
|
||||
@Override public void tick() {
|
||||
if(loading!=null && loading.isDone()) {
|
||||
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
|
||||
}
|
||||
}
|
||||
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
|
||||
@Override public void removed() {closeTextures();}
|
||||
@Override public boolean isPauseScreen() {return false;}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityTask;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
final class CommunityTaskScreen extends BlocodexFrame {
|
||||
private final Consumer<CommunityTask> save;
|
||||
private CommunityTask.Location location;
|
||||
private List<CommunityTask.Material> materials,rewards;
|
||||
private String audience,reward,custom,error="";
|
||||
CommunityTaskScreen(Screen parent,CommunityTask task,Consumer<CommunityTask> save){super(parent);this.save=save;location=task.location();materials=task.materials();rewards=task.rewards();audience=task.audience();reward=task.reward();custom=task.customReward();}
|
||||
@Override protected void init(){
|
||||
initFrame("history",text("task_configure"));int w=Math.min(400,contentWidth-24);
|
||||
var layout=LinearLayout.vertical().spacing(8);
|
||||
if(!error.isEmpty())layout.addChild(new MultiLineTextWidget(text(error),font).setMaxWidth(w));
|
||||
layout.addChild(new MultiLineTextWidget(text("exchange_help"),font).setMaxWidth(w));
|
||||
button(layout,w,"location_current",()->{if(minecraft.player!=null){var p=minecraft.player.blockPosition();location=new CommunityTask.Location(minecraft.player.level().dimension().identifier().toString(),p.getX(),p.getY(),p.getZ());rebuildWidgets();}});
|
||||
if(location!=null){
|
||||
layout.addChild(new MultiLineTextWidget(text("location_value",location.dimension(),location.x(),location.y(),location.z()),font).setMaxWidth(w));
|
||||
button(layout,w,"location_remove",()->{location=null;rebuildWidgets();});
|
||||
}
|
||||
button(layout,w,"materials",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,materials,value->materials=value)));
|
||||
CommunityMaterialsScreen.showItems(layout,w,materials);
|
||||
button(layout,w,"audience."+audience,()->{audience=audience.equals("shared")?"single":"shared";rebuildWidgets();});
|
||||
button(layout,w,"reward."+reward,()->{reward=switch(reward){case "none"->"items";case "items"->"custom";default->"none";};rebuildWidgets();});
|
||||
if(reward.equals("items")){button(layout,w,"rewards_choose",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,rewards,value->rewards=value)));CommunityMaterialsScreen.showItems(layout,w,rewards);}
|
||||
if(reward.equals("custom")){var box=layout.addChild(new EditBox(font,w,20,text("reward.custom")));box.setMaxLength(240);box.setValue(custom);box.setResponder(value->custom=value);}
|
||||
button(layout,w,"save",()->{try{save.accept(new CommunityTask(location,materials,audience,reward,reward.equals("items")?rewards:List.of(),reward.equals("custom")?custom:""));onClose();}catch(fr.koka.sanctuary.community.Community.Refused e){error="task_invalid";rebuildWidgets();}});
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition((width-w)/2,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void button(LinearLayout layout,int w,String key,Runnable action){layout.addChild(Button.builder(text(key),b->action.run()).width(w).build());}
|
||||
@Override public boolean isPauseScreen(){return false;}
|
||||
}
|
||||
@@ -21,7 +21,20 @@ public final class HotbarClient {
|
||||
private static boolean held;
|
||||
private static boolean worldHeld;
|
||||
private HotbarClient() {}
|
||||
static void migratePlayerListKey(net.minecraft.client.Minecraft client) {
|
||||
var marker=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
|
||||
if(java.nio.file.Files.exists(marker))return;
|
||||
try {
|
||||
if(client.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB))) {
|
||||
client.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U));
|
||||
KeyMapping.resetMapping();client.options.save();
|
||||
}
|
||||
java.nio.file.Files.createDirectories(marker.getParent());
|
||||
java.nio.file.Files.writeString(marker,"beta.161\n",java.nio.file.StandardOpenOption.CREATE_NEW);
|
||||
} catch(java.io.IOException error) {fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot record player list key migration",error);}
|
||||
}
|
||||
public static void register(KeyMapping.Category category) {
|
||||
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.CLIENT_STARTED.register(HotbarClient::migratePlayerListKey);
|
||||
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("screen.sanctuary.inventory.rotate_key",InputConstants.Type.KEYBOARD,InputConstants.KEY_TAB,category));
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(HotbarPayloads.Capability.TYPE,(p,c) -> {});
|
||||
ClientPlayNetworking.registerGlobalReceiver(HotbarPayloads.State.TYPE,(p,c) -> c.client().execute(() -> {
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class NotificationClient {
|
||||
private static double animatedBlocks,animatedItems,animatedRecipes;
|
||||
private static boolean hasRoom=true;
|
||||
private static String trackingScope;
|
||||
private static List<Track> communityTracks=List.of();
|
||||
private NotificationClient(){}
|
||||
public static NotificationOptions options(){if(config==null)config=NotificationOptions.load();return config;}
|
||||
public static Component label(String key,Object...args){return Component.translatable("sanctuary.notifications."+key,args);}
|
||||
@@ -40,10 +41,24 @@ public final class NotificationClient {
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
|
||||
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
|
||||
}
|
||||
public static void reset(){FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
|
||||
public static void reset(){CommunityMapMarkers.clear();communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
|
||||
public static void clearFeed(){FEED.clear();}
|
||||
public static List<NotificationFeed.Entry<Message>> messages(){return FEED.entries();}
|
||||
public static List<Track> tracked(){return OBJECTIVES.shown(options().pinnedAdvancements,options().advancements);}
|
||||
public static List<Track> tracked(){var tracks=new ArrayList<>(OBJECTIVES.shown(options().pinnedAdvancements,options().advancements));if(options().pinnedAdvancements)tracks.addAll(communityTracks);return tracks.stream().distinct().limit(6).toList();}
|
||||
public static void communitySubscribed(Component title){push("community:follow",2,CommunityClient.text("followed",title),0x80c1a4);}
|
||||
public static void community(com.google.gson.JsonArray entries){
|
||||
var tracks=new ArrayList<Track>();var snapshots=new ArrayList<ObjectiveTracker.Objective<Track>>();
|
||||
for(var value:entries){
|
||||
var e=value.getAsJsonObject();var task=new com.google.gson.Gson().fromJson(e.get("task"),fr.koka.sanctuary.community.CommunityTask.class);
|
||||
boolean closed=CommunityClient.str(e,"status").equals("closed");
|
||||
Component detail=CommunityClient.text(closed?"closed":"open");
|
||||
if(task.location()!=null)detail=detail.copy().append(" · "+task.location().x()+", "+task.location().y()+", "+task.location().z());
|
||||
if(!task.materials().isEmpty()) {var item=task.materials().getFirst();var name=BuiltInRegistries.ITEM.getOptional(Identifier.parse(item.item())).map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));detail=detail.copy().append(" · "+item.count()+" × ").append(name);}
|
||||
var track=new Track(Component.literal(CommunityClient.str(e,"title")),detail,closed?1:0,CommunityCard.color(CommunityClient.str(e,"category"))&0xffffff);
|
||||
snapshots.add(new ObjectiveTracker.Objective<>(CommunityClient.str(e,"id"),track,closed?1:0,closed));if(!closed&&tracks.size()<3)tracks.add(track);
|
||||
}
|
||||
objectives("community",snapshots);communityTracks=List.copyOf(tracks);
|
||||
}
|
||||
public static boolean isPinned(Identifier id){return OBJECTIVES.isPinned(new ObjectiveTracker.Key("advancement",id.toString()));}
|
||||
/** Presentation entry point for validated server snapshots, on the Minecraft client thread.
|
||||
* A quest adapter can use source "quest" and its own IDs without a second HUD. */
|
||||
@@ -111,7 +126,7 @@ public final class NotificationClient {
|
||||
long now=System.nanoTime()/1_000_000L,elapsed=lastClock==0?0:now-lastClock;lastClock=now;
|
||||
if(m.player==null||m.level==null)return;
|
||||
if(m.player!=inhabitant||!m.player.isAlive()){
|
||||
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();
|
||||
inhabitant=m.player;FEED.clear();OBJECTIVES.clearSnapshots();communityTracks=List.of();
|
||||
}
|
||||
if(!m.player.isAlive())return;
|
||||
ensureTracking(m);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ public final class VanillaLight {
|
||||
private static Preferences preferences;
|
||||
private VanillaLight() { }
|
||||
private static final class Preferences {
|
||||
boolean enabled=true;
|
||||
// Opt-in on fresh installations; explicit saved choices remain authoritative.
|
||||
boolean enabled=false;
|
||||
Boolean naturalWater; // Legacy OFF is an explicit opt-out in both world types.
|
||||
Boolean sanctuaryNaturalWater;
|
||||
Boolean ordinaryNaturalWater;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
|
||||
/** Server-thread observations, bounded asynchronous persistence; no web requests touch the game thread. */
|
||||
public final class ServerMetrics {
|
||||
private static final Map<MinecraftServer,Session> SESSIONS=new HashMap<>();
|
||||
private ServerMetrics() { }
|
||||
public static void register() {
|
||||
ServerLifecycleEvents.SERVER_STARTED.register(server -> {if(!server.isSingleplayer()) {var s=new Session(server);SESSIONS.put(server,s);s.capture(true);}});
|
||||
ServerTickEvents.END_SERVER_TICK.register(server -> {var s=SESSIONS.get(server);if(s!=null && System.nanoTime()-s.lastCapture>=30_000_000_000L)s.capture(true);});
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{var s=SESSIONS.get(server);if(s!=null)s.observe(handler.player);});
|
||||
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {var s=SESSIONS.remove(server);if(s!=null)s.stop();});
|
||||
}
|
||||
private static boolean actor(ServerPlayer player) {
|
||||
String name=player.getGameProfile().name();
|
||||
return name.startsWith("Lab_") && player.getUUID().equals(UUID.nameUUIDFromBytes(("SanctuaryDuo:"+name).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
private static final class Session {
|
||||
final MinecraftServer server;
|
||||
final ThreadPoolExecutor worker=new ThreadPoolExecutor(1,1,0L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(2),r->{var t=new Thread(r,"sanctuary-metrics");t.setDaemon(true);return t;});
|
||||
final Map<String,Long> latest=new HashMap<>();
|
||||
final Set<String> actors=new HashSet<>();
|
||||
final long started=System.nanoTime()/1_000_000;
|
||||
ServerMetricsLedger ledger;
|
||||
CommunityConfig config;
|
||||
boolean broken;
|
||||
long lastCapture,lastSqlWarning;
|
||||
Session(MinecraftServer server){this.server=server;}
|
||||
void observe(ServerPlayer player) {
|
||||
if(actor(player))actors.add(player.getStringUUID());
|
||||
else latest.put(player.getStringUUID(),(long)player.getStats().getValue(Stats.CUSTOM.get(Stats.DEATHS)));
|
||||
}
|
||||
void capture(boolean running) {
|
||||
lastCapture=System.nanoTime();
|
||||
int online=0;
|
||||
for(var player:server.getPlayerList().getPlayers()) {observe(player);if(!actor(player))online++;}
|
||||
var values=Map.copyOf(latest);var excluded=Set.copyOf(actors);
|
||||
long monotonic=lastCapture/1_000_000,wall=System.currentTimeMillis();int count=running?online:0;
|
||||
try {worker.execute(()->persist(monotonic,wall,values,excluded,count,running));}
|
||||
catch(RejectedExecutionException error){SanctuaryMod.LOGGER.warn("Server metrics worker busy; next sample will include elapsed time");}
|
||||
}
|
||||
void persist(long monotonic,long wall,Map<String,Long> values,Set<String> excluded,int online,boolean running) {
|
||||
if(broken)return;
|
||||
ServerMetricsLedger.Snapshot snapshot;
|
||||
try {
|
||||
if(ledger==null)ledger=new ServerMetricsLedger(server.getWorldPath(LevelResource.ROOT),started);
|
||||
snapshot=ledger.sample(monotonic,wall,values,excluded,online,running);
|
||||
} catch(Exception error) {broken=true;SanctuaryMod.LOGGER.error("Server metrics stopped; existing local data preserved ({})",error.getClass().getSimpleName());return;}
|
||||
try {
|
||||
if(config==null)config=CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
if(!config.mode().equals("database"))return;
|
||||
String password=System.getenv(config.passwordEnv());if(password==null)throw new IllegalStateException("Missing metrics database credential");
|
||||
var props=new Properties();props.setProperty("user",config.user());props.setProperty("password",password);props.setProperty("connectTimeout","2000");props.setProperty("socketTimeout","3000");
|
||||
try(var c=DriverManager.getConnection(config.jdbcUrl(),props);var stmt=c.prepareStatement("INSERT INTO sanctuary_server_metrics (server_id,uptime_millis,deaths,online_players,updated_at,running) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE uptime_millis=VALUES(uptime_millis),deaths=VALUES(deaths),online_players=VALUES(online_players),updated_at=VALUES(updated_at),running=VALUES(running)")) {
|
||||
stmt.setQueryTimeout(3);stmt.setString(1,config.serverId());stmt.setLong(2,snapshot.uptimeMillis());stmt.setLong(3,snapshot.deaths());stmt.setInt(4,snapshot.onlinePlayers());stmt.setLong(5,snapshot.updatedAt());stmt.setBoolean(6,snapshot.running());stmt.executeUpdate();
|
||||
}
|
||||
} catch(Exception error) {
|
||||
if(lastSqlWarning==0 || wall-lastSqlWarning>=300_000){lastSqlWarning=wall;SanctuaryMod.LOGGER.warn("Server metrics publication unavailable; local measurements retained ({})",error.getClass().getSimpleName());}
|
||||
}
|
||||
}
|
||||
void stop() {
|
||||
capture(false);worker.shutdown();
|
||||
try {if(!worker.awaitTermination(8,TimeUnit.SECONDS))worker.shutdownNow();}
|
||||
catch(InterruptedException error){worker.shutdownNow();Thread.currentThread().interrupt();}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import com.google.gson.*;
|
||||
import fr.koka.sanctuary.progression.AtomicJson;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Additive ledger: elapsed process time, vanilla deaths, and explicitly excluded lab actors. */
|
||||
public final class ServerMetricsLedger {
|
||||
private static final Gson JSON = new Gson();
|
||||
private final Path path;
|
||||
private byte[] previous;
|
||||
private long uptimeMillis;
|
||||
private final Map<String,Long> deaths = new HashMap<>();
|
||||
private final Set<String> excluded = new HashSet<>();
|
||||
private long sampledAt;
|
||||
public record Snapshot(long uptimeMillis, long deaths, int onlinePlayers, long updatedAt, boolean running) { }
|
||||
public ServerMetricsLedger(Path root, long monotonicMillis) throws Exception {
|
||||
path=root.resolve("data/sanctuary-server-metrics.json");
|
||||
sampledAt=monotonicMillis;
|
||||
previous=AtomicJson.read(path,4*1024*1024);
|
||||
if(previous!=null) {
|
||||
var data=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8));
|
||||
AtomicJson.fields(data,"version","uptimeMillis","deaths","excluded");
|
||||
var o=data.getAsJsonObject();
|
||||
if(o.get("version").getAsInt()!=1 || (uptimeMillis=o.get("uptimeMillis").getAsLong())<0)throw new IllegalArgumentException("Metrics version or uptime");
|
||||
for(var e:o.getAsJsonObject("deaths").entrySet()) {Community.uuid(e.getKey());long value=e.getValue().getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");deaths.put(e.getKey(),value);}
|
||||
for(var e:o.getAsJsonArray("excluded"))excluded.add(Community.uuid(e.getAsString()));
|
||||
}
|
||||
var stats=root.resolve("stats");
|
||||
if(Files.isDirectory(stats))try(var files=Files.list(stats)) {
|
||||
for(var file:files.filter(f->f.getFileName().toString().matches("[0-9a-f-]{36}\\.json")).toList()) {
|
||||
String id=Community.uuid(file.getFileName().toString().substring(0,36));
|
||||
byte[] bytes=AtomicJson.read(file,8*1024*1024);
|
||||
var o=AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
var all=o.getAsJsonObject("stats");var custom=all==null?null:all.getAsJsonObject("minecraft:custom");
|
||||
if(custom!=null && custom.has("minecraft:deaths")) {
|
||||
long value=custom.get("minecraft:deaths").getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");
|
||||
deaths.merge(id,value,Math::max);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public Snapshot sample(long monotonicMillis,long wallMillis,Map<String,Long> currentDeaths,Set<String> actors,int online,boolean running) throws Exception {
|
||||
if(monotonicMillis<sampledAt || wallMillis<0 || online<0)throw new IllegalArgumentException("Metrics clock");
|
||||
uptimeMillis=Math.addExact(uptimeMillis,monotonicMillis-sampledAt);sampledAt=monotonicMillis;
|
||||
excluded.addAll(actors);
|
||||
for(var e:currentDeaths.entrySet()) {Community.uuid(e.getKey());if(e.getValue()<0)throw new IllegalArgumentException("Deaths");deaths.merge(e.getKey(),e.getValue(),Math::max);}
|
||||
excluded.forEach(deaths::remove);
|
||||
var o=new JsonObject();o.addProperty("version",1);o.addProperty("uptimeMillis",uptimeMillis);o.add("deaths",JSON.toJsonTree(deaths));o.add("excluded",JSON.toJsonTree(excluded));
|
||||
previous=AtomicJson.write(path,previous,JSON.toJson(o),4*1024*1024);
|
||||
long total=0;for(long value:deaths.values())total=Math.addExact(total,value);
|
||||
return new Snapshot(uptimeMillis,total,online,wallMillis,running);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.minecraft.client.Options;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArgs;
|
||||
import org.spongepowered.asm.mixin.injection.invoke.arg.Args;
|
||||
|
||||
@Mixin(Options.class)
|
||||
public abstract class PlayerListKeyMixin {
|
||||
@ModifyArgs(method = "<init>", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyMapping;<init>(Ljava/lang/String;ILnet/minecraft/client/KeyMapping$Category;)V"))
|
||||
private void sanctuary$playerListDefault(Args args) {
|
||||
if ("key.playerlist".equals(args.get(0))) args.set(1, InputConstants.KEY_U);
|
||||
}
|
||||
}
|
||||
@@ -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,46 @@
|
||||
"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.",
|
||||
"sanctuary.community.follow_map_active": "Following: a map marker appears when a location is provided.",
|
||||
"sanctuary.community.follow_map_help": "Replying does not subscribe you. Follow this request to display its map marker.",
|
||||
"sanctuary.community.notice_full_help": "The single place is taken. You can reply, but cannot follow this request."
|
||||
}
|
||||
|
||||
@@ -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,47 @@
|
||||
"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.",
|
||||
"sanctuary.community.follow_map_active": "Demande suivie : son repère apparaît sur la carte si un lieu est indiqué.",
|
||||
"sanctuary.community.follow_map_help": "Répondre ne vous abonne pas. Suivez la demande pour afficher son repère sur la carte.",
|
||||
"sanctuary.community.notice_full_help": "La place unique est occupée. Vous pouvez répondre, mais pas suivre cette demande."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -289,7 +289,8 @@
|
||||
"client.NaturalWaterFogColorMixin",
|
||||
"client.NaturalWaterSectionCopyAccess",
|
||||
"client.PauseAtlasServerMixin",
|
||||
"client.CommunityPauseMixin"
|
||||
"client.CommunityPauseMixin",
|
||||
"client.PlayerListKeyMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.koka.sanctuary.community;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
public final class ServerMetricsSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path root=Files.createTempDirectory("sanctuary-metrics-test");
|
||||
String player="11111111-1111-4111-8111-111111111111",bot="22222222-2222-4222-8222-222222222222";
|
||||
Files.createDirectories(root.resolve("stats"));
|
||||
Files.writeString(root.resolve("stats/"+player+".json"),"{\"stats\":{\"minecraft:custom\":{\"minecraft:deaths\":12}}}");
|
||||
var ledger=new ServerMetricsLedger(root,100);
|
||||
var first=ledger.sample(3100,10000,Map.of(player,13L,bot,9L),Set.of(bot),1,true);
|
||||
check(first.uptimeMillis()==3000 && first.deaths()==13 && first.onlinePlayers()==1,"imports offline deaths, counts current deaths, excludes actors");
|
||||
ledger.sample(4100,11000,Map.of(),Set.of(),0,false);
|
||||
var restarted=new ServerMetricsLedger(root,900000);
|
||||
var next=restarted.sample(901000,9000000,Map.of(player,13L),Set.of(),1,true);
|
||||
check(next.uptimeMillis()==5000 && next.deaths()==13,"downtime excluded and deaths not counted twice");
|
||||
var death=restarted.sample(902000,9001000,Map.of(player,14L),Set.of(),1,true);
|
||||
check(death.deaths()==14,"new death increments cumulative total");
|
||||
Path file=root.resolve("data/sanctuary-server-metrics.json");Files.writeString(file,"corrupt");
|
||||
try{restarted.sample(903000,9002000,Map.of(),Set.of(),0,false);throw new AssertionError("overwrite");}catch(java.io.IOException expected){}
|
||||
check(Files.readString(file).equals("corrupt"),"external edit preserved");
|
||||
try{new ServerMetricsLedger(root,0);throw new AssertionError("reset");}catch(IllegalArgumentException expected){}
|
||||
System.out.println("SERVER_METRICS161_PASS");
|
||||
}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.156"
|
||||
version = "beta.161"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local, isolated duo lab. No personal saves, accounts or launcher settings touched."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import html
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAB = ROOT / "build" / "duo"
|
||||
|
||||
|
||||
def create(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def prepare(args):
|
||||
server = LAB / "server"
|
||||
create(server / "server.properties", """server-ip=127.0.0.1
|
||||
server-port=25575
|
||||
online-mode=false
|
||||
white-list=false
|
||||
enforce-secure-profile=false
|
||||
enable-query=false
|
||||
enable-rcon=false
|
||||
level-name=duo-flat-160
|
||||
level-type=sanctuary_test:flat
|
||||
level-seed=160
|
||||
gamemode=creative
|
||||
difficulty=peaceful
|
||||
max-players=6
|
||||
view-distance=4
|
||||
simulation-distance=3
|
||||
pause-when-empty-seconds=30
|
||||
spawn-protection=0
|
||||
motd=Sanctuary DUO — local test lab
|
||||
""")
|
||||
create(server / "eula.txt", "eula=true\n")
|
||||
config = {"mode": args.storage, "serverId": args.server_id or str(uuid.uuid4()),
|
||||
"jdbcUrl": args.jdbc, "user": "root", "passwordEnv": "SANCTUARY_COMMUNITY_DB_PASSWORD"}
|
||||
create(server / "config/sanctuary-community.json", json.dumps(config, indent=2) + "\n")
|
||||
create(LAB / "client/config/sanctuary-shaders.json", '{"enabled":false}\n')
|
||||
create(LAB / "client/options.txt", "renderDistance:6\nsimulationDistance:4\nmaxFps:30\nenableVsync:true\nguiScale:2\nlang:fr_fr\npauseOnLostFocus:false\n")
|
||||
print("Prepared", LAB, "— existing files preserved. Server: 127.0.0.1:25575")
|
||||
|
||||
|
||||
def launch(args):
|
||||
java_home = os.environ.get("JAVA_HOME")
|
||||
if not java_home:
|
||||
java_home = subprocess.check_output(["/usr/libexec/java_home", "-v", "25"], text=True).strip()
|
||||
java = str(Path(java_home) / "bin/java")
|
||||
argfile = LAB / (args.command + ".args")
|
||||
if not argfile.is_file():
|
||||
raise SystemExit("Run ./gradlew :sanctuary-test:exportDuoLaunch first.")
|
||||
if not 512 <= args.memory <= (2048 if args.command == "server" else 3072):
|
||||
raise SystemExit("Memory must be 512–2048 MiB (server) or 512–3072 MiB (client).")
|
||||
# JVM overrides must precede the main class, therefore rewrite a temporary argfile.
|
||||
lines = argfile.read_text().splitlines()
|
||||
lines = [f'"-Xmx{args.memory}m"' if line.startswith('"-Xmx') else line for line in lines]
|
||||
if args.command == "client":
|
||||
lines += ['"--quickPlayMultiplayer"', '"127.0.0.1:25575"']
|
||||
runtime = LAB / (args.command + "-runtime.args")
|
||||
runtime.write_text("\n".join(lines) + "\n")
|
||||
os.chdir(LAB / args.command)
|
||||
print(f"Starting {args.command}: heap cap {args.memory} MiB; native/GPU memory is additional.", flush=True)
|
||||
os.execv(java, [java, "@" + str(runtime)])
|
||||
|
||||
|
||||
def record(args):
|
||||
if not 1 <= args.seconds <= 300:
|
||||
raise SystemExit("Sessions are limited to 1–300 seconds.")
|
||||
windows = json.loads(subprocess.check_output(["swift", str(ROOT / "scripts/minecraft_windows.swift")], text=True))
|
||||
if args.window not in [window["id"] for window in windows]:
|
||||
raise SystemExit("This ID is not a visible Minecraft window. Run the windows command first.")
|
||||
# Explicit window ID prevents accidentally recording another screen or app.
|
||||
session = LAB / "sessions" / dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
session.mkdir(parents=True)
|
||||
create(session / "notes.md", "# Séance duo\n\nScène :\nAttendu :\nObservations ergonomiques :\nVérifications fonctionnelles :\nDécisions :\n")
|
||||
(session / "manifest.json").write_text(json.dumps({"window": args.window, "duration": args.seconds,
|
||||
"interval": 2, "audio": False, "format": "timestamped PNG sequence", "started": dt.datetime.now().astimezone().isoformat()}, indent=2))
|
||||
print("Recording selected Minecraft window only, no audio:", session, flush=True)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
while time.monotonic() - started < args.seconds:
|
||||
elapsed = time.monotonic() - started
|
||||
output = session / f"{elapsed:07.2f}.png"
|
||||
subprocess.run(["/usr/sbin/screencapture", "-x", "-o", "-l", str(args.window), str(output)], check=True)
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
frames = sorted(session.glob("*.png"))
|
||||
rows = "\n".join(f'<figure><figcaption>{html.escape(frame.stem)} s</figcaption><a href="{frame.name}"><img loading="lazy" src="{frame.name}"></a></figure>' for frame in frames)
|
||||
(session / "index.html").write_text('<!doctype html><meta charset="utf-8"><title>Séance Sanctuary</title>'
|
||||
'<style>body{background:#171c1a;color:#f2f2e7;font:16px system-ui;margin:24px}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px}figure{margin:0}img{width:100%}</style>'
|
||||
'<h1>Séance Sanctuary</h1><p>Une capture toutes les deux secondes. Cliquer pour voir en grand.</p><main>' + rows + '</main>')
|
||||
print("Session saved:", session)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
setup = sub.add_parser("prepare")
|
||||
setup.add_argument("--storage", choices=["file", "database"], default="file")
|
||||
setup.add_argument("--server-id")
|
||||
setup.add_argument("--jdbc", default="jdbc:mariadb://127.0.0.1:33077/sanctuary_community_tasks158_interop")
|
||||
sub.add_parser("windows", help="List only visible Minecraft window IDs")
|
||||
for command, memory in [("server", 768), ("client", 2048)]:
|
||||
item = sub.add_parser(command)
|
||||
item.add_argument("--memory", type=int, default=memory)
|
||||
capture = sub.add_parser("record")
|
||||
capture.add_argument("--window", type=int, required=True, help="Verified Minecraft window ID; never the desktop")
|
||||
capture.add_argument("--seconds", type=int, default=120)
|
||||
args = parser.parse_args()
|
||||
if args.command == "prepare":
|
||||
prepare(args)
|
||||
elif args.command == "windows":
|
||||
subprocess.run(["swift", str(ROOT / "scripts/minecraft_windows.swift")], check=True)
|
||||
elif args.command == "record":
|
||||
record(args)
|
||||
else:
|
||||
launch(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// Only list Minecraft/Java game windows, never titles from other applications.
|
||||
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let matches = windows.compactMap { window -> [String: Any]? in
|
||||
let owner = window[kCGWindowOwnerName as String] as? String ?? ""
|
||||
let title = window[kCGWindowName as String] as? String ?? ""
|
||||
guard ["java", "Minecraft"].contains(owner), title.hasPrefix("Sanctuary - ") || title.hasPrefix("Minecraft") else { return nil }
|
||||
return ["id": window[kCGWindowNumber as String] ?? 0, "pid": window[kCGWindowOwnerPID as String] ?? 0, "title": title]
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: matches, options: [.prettyPrinted, .sortedKeys])
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
Reference in New Issue
Block a user