Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19aa8054a5 | ||
|
|
9791d178ab |
@@ -1,5 +1,17 @@
|
||||
# Sanctuary
|
||||
|
||||
## 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,41 @@ 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.
|
||||
|
||||
@@ -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`.
|
||||
+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.158
|
||||
pack_version=beta.158
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -258,6 +258,12 @@ tasks.named('processGametestResources') {
|
||||
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
|
||||
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
|
||||
def cards158Client = providers.gradleProperty('sanctuaryCards158ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryCards158ClientTests', cards158Client)
|
||||
if (cards158Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community158ClientChecks') }
|
||||
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
|
||||
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
|
||||
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryPause155ClientTests', pause155Client)
|
||||
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
|
||||
|
||||
+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)); }
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
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);
|
||||
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),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,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->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("cards158-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("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");});
|
||||
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.bulletin"),"Public navigation excludes linking and bulletin");
|
||||
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"));
|
||||
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->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");m.gui.setScreen(null);});
|
||||
System.out.println("CARDS158_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();}
|
||||
}
|
||||
+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();}
|
||||
}
|
||||
@@ -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"))));
|
||||
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,CommunityScreen.date(entry),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 && 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"));
|
||||
if (overviewRequest) {
|
||||
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
|
||||
else { stale = true; overviewError = str(result,"error"); }
|
||||
|
||||
@@ -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"),()->{});
|
||||
add.accept(bulletin);
|
||||
String[] sections={"blocks","history","progress","identity"};
|
||||
int optionsTop=screen.height-76;
|
||||
@@ -74,7 +72,7 @@ public final class CommunityPausePanels implements AutoCloseable {
|
||||
if(!overviewError.isEmpty())message=text(overviewError);
|
||||
bulletin.setMessage(message);
|
||||
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
|
||||
bulletin.active=available();
|
||||
bulletin.active=false;
|
||||
}
|
||||
public void tick() {
|
||||
if(closed)return;
|
||||
|
||||
@@ -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,7 +9,7 @@ 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 String id, editingId = "", message = "", expandedId = "";
|
||||
@@ -18,14 +18,31 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
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.equals("bulletin")&&!admin?"article":kind; this.id = id; this.composing = compose;
|
||||
moderator = flag(overview,"moderator");
|
||||
if (compose) useDraft("new:" + kind);
|
||||
}
|
||||
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
|
||||
@Override protected void init() {
|
||||
initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
|
||||
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 +51,39 @@ 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(!kind.equals("bulletin"))for(String target:new String[]{"article","notice"})tabs.addChild(Button.builder(text(target),b->open(parent,target,"",false)).width((w-6)/2).build());
|
||||
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(6));
|
||||
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 +103,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")))));
|
||||
@@ -99,7 +131,7 @@ public final class CommunityScreen extends BlocodexFrame {
|
||||
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 +146,70 @@ 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()));
|
||||
if(!task.materials().isEmpty()) {line(layout,w,text("materials"));CommunityMaterialsScreen.showItems(layout,w,task.materials());}
|
||||
line(layout,w,text("audience."+task.audience()));line(layout,w,text("reward."+task.reward()));
|
||||
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
|
||||
if(task.reward().equals("custom"))line(layout,w,Component.literal(task.customReward()));
|
||||
line(layout,w,text("exchange_help"));
|
||||
int count=data.has("members")?data.getAsJsonArray("members").size():0;
|
||||
line(layout,w,text("followers",count));
|
||||
if(data.has("members"))for(var member:data.getAsJsonArray("members"))line(layout,w,Component.literal("• "+str(member.getAsJsonObject(),"playerName")));
|
||||
boolean subscribed=flag(data,"subscribed");
|
||||
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
|
||||
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
|
||||
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
|
||||
rebuildWidgets();
|
||||
})).width(w).build());
|
||||
follow.active=subscribed || (!own(entry)&&str(entry,"status").equals("open")&&(!task.audience().equals("single")||count==0));
|
||||
}
|
||||
private void mutate(String action,JsonObject entry) {
|
||||
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
|
||||
message=result.has("error")?str(result,"error"):"saved";
|
||||
@@ -151,28 +227,38 @@ 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 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(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();}
|
||||
}
|
||||
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","","",0,page,result -> {
|
||||
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.min(420,contentWidth);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,Math.min(180,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=w>=360?4:3,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,Math.min(180,contentBottom-contentTop-28)),ScrollableLayout.ReserveStrategy.RIGHT);
|
||||
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void preview() {
|
||||
int w=Math.min(260,contentWidth-16),h=Math.max(24,Math.min(146,contentBottom-contentTop-34));
|
||||
int left=(width-w)/2;
|
||||
try {
|
||||
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
|
||||
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
|
||||
textures.add(widget);widget.setPosition(left,contentTop);addRenderableWidget(widget);
|
||||
} catch(Exception e) {error="photo_invalid";selected=null;return;}
|
||||
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(left,contentTop+h+8,(w-6)/2,20).build());
|
||||
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(left+(w+6)/2,contentTop+h+8,(w-6)/2,20).build());
|
||||
}
|
||||
@Override public void tick() {
|
||||
if(loading!=null && loading.isDone()) {
|
||||
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
|
||||
}
|
||||
}
|
||||
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
|
||||
@Override public void removed() {closeTextures();}
|
||||
@Override public boolean isPauseScreen() {return false;}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.community.CommunityTask;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import static fr.koka.sanctuary.client.CommunityClient.text;
|
||||
|
||||
final class CommunityTaskScreen extends BlocodexFrame {
|
||||
private final Consumer<CommunityTask> save;
|
||||
private CommunityTask.Location location;
|
||||
private List<CommunityTask.Material> materials,rewards;
|
||||
private String audience,reward,custom,error="";
|
||||
CommunityTaskScreen(Screen parent,CommunityTask task,Consumer<CommunityTask> save){super(parent);this.save=save;location=task.location();materials=task.materials();rewards=task.rewards();audience=task.audience();reward=task.reward();custom=task.customReward();}
|
||||
@Override protected void init(){
|
||||
initFrame("history",text("task_configure"));int w=Math.min(400,contentWidth-24);
|
||||
var layout=LinearLayout.vertical().spacing(8);
|
||||
if(!error.isEmpty())layout.addChild(new MultiLineTextWidget(text(error),font).setMaxWidth(w));
|
||||
layout.addChild(new MultiLineTextWidget(text("exchange_help"),font).setMaxWidth(w));
|
||||
button(layout,w,"location_current",()->{if(minecraft.player!=null){var p=minecraft.player.blockPosition();location=new CommunityTask.Location(minecraft.player.level().dimension().identifier().toString(),p.getX(),p.getY(),p.getZ());rebuildWidgets();}});
|
||||
if(location!=null){
|
||||
layout.addChild(new MultiLineTextWidget(text("location_value",location.dimension(),location.x(),location.y(),location.z()),font).setMaxWidth(w));
|
||||
button(layout,w,"location_remove",()->{location=null;rebuildWidgets();});
|
||||
}
|
||||
button(layout,w,"materials",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,materials,value->materials=value)));
|
||||
CommunityMaterialsScreen.showItems(layout,w,materials);
|
||||
button(layout,w,"audience."+audience,()->{audience=audience.equals("shared")?"single":"shared";rebuildWidgets();});
|
||||
button(layout,w,"reward."+reward,()->{reward=switch(reward){case "none"->"items";case "items"->"custom";default->"none";};rebuildWidgets();});
|
||||
if(reward.equals("items")){button(layout,w,"rewards_choose",()->minecraft.gui.setScreen(new CommunityMaterialsScreen(this,rewards,value->rewards=value)));CommunityMaterialsScreen.showItems(layout,w,rewards);}
|
||||
if(reward.equals("custom")){var box=layout.addChild(new EditBox(font,w,20,text("reward.custom")));box.setMaxLength(240);box.setValue(custom);box.setResponder(value->custom=value);}
|
||||
button(layout,w,"save",()->{try{save.accept(new CommunityTask(location,materials,audience,reward,reward.equals("items")?rewards:List.of(),reward.equals("custom")?custom:""));onClose();}catch(fr.koka.sanctuary.community.Community.Refused e){error="task_invalid";rebuildWidgets();}});
|
||||
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop),ScrollableLayout.ReserveStrategy.RIGHT);scroll.setPosition((width-w)/2,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
|
||||
}
|
||||
private void button(LinearLayout layout,int w,String key,Runnable action){layout.addChild(Button.builder(text(key),b->action.run()).width(w).build());}
|
||||
@Override public boolean isPauseScreen(){return false;}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public final class NotificationClient {
|
||||
private static double animatedBlocks,animatedItems,animatedRecipes;
|
||||
private static boolean hasRoom=true;
|
||||
private static String trackingScope;
|
||||
private static List<Track> communityTracks=List.of();
|
||||
private NotificationClient(){}
|
||||
public static NotificationOptions options(){if(config==null)config=NotificationOptions.load();return config;}
|
||||
public static Component label(String key,Object...args){return Component.translatable("sanctuary.notifications."+key,args);}
|
||||
@@ -40,10 +41,24 @@ public final class NotificationClient {
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
|
||||
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
|
||||
}
|
||||
public static void reset(){FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
|
||||
public static void reset(){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);
|
||||
|
||||
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -59,21 +70,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 +102,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,6 +79,7 @@ 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").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()));
|
||||
@@ -75,10 +87,54 @@ public final class CommunityNetwork {
|
||||
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 "cards" -> {
|
||||
if(!Set.of("article","notice").contains(request.kind()))fail("invalid");
|
||||
var page=store.list(request.kind(),"",request.page());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());
|
||||
if(thread.entry().kind().equals("bulletin")&&!actor.moderator())fail("forbidden");
|
||||
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 +145,54 @@ 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->e.getAsJsonObject().remove("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,14 @@ 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;
|
||||
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,12 +24,16 @@ 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");
|
||||
@@ -53,15 +59,38 @@ public final class FileCommunityStore implements CommunityStore {
|
||||
var candidate = new LinkedHashMap<>(entries);
|
||||
boolean[] dirty = {false};
|
||||
Entry result = Community.apply(new Transaction() {
|
||||
public int followers(String id) {return members(id).size();}
|
||||
public Entry get(String id) { return candidate.get(id); }
|
||||
public void save(Entry entry, boolean insert) { candidate.put(entry.id(), entry); dirty[0] = true; }
|
||||
}, actor, change, clock.millis());
|
||||
if (dirty[0]) {
|
||||
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
|
||||
root.add("entries", JSON.toJsonTree(candidate.values()));
|
||||
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
|
||||
entries = candidate;
|
||||
persist(candidate,result.status().equals("hidden")?followers.stream().filter(f->!f.noticeId().equals(result.id())).toList():followers);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private void persist(Map<String,Entry> candidate,List<CommunityTask.Follow> nextFollowers) throws Exception {
|
||||
var root=new JsonObject();root.addProperty("version",VERSION);root.addProperty("serverId",serverId);
|
||||
root.add("entries",JSON.toJsonTree(candidate.values()));root.add("followers",JSON.toJsonTree(nextFollowers));
|
||||
if(previous!=null && legacy<VERSION) {
|
||||
Path backup=path.resolveSibling(path.getFileName()+".v"+legacy+".bak");
|
||||
if(Files.exists(backup)) {if(!Arrays.equals(AtomicJson.read(backup,LIMIT),previous))throw new java.io.IOException("Different migration backup");}
|
||||
else Files.write(backup,previous,StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
previous=AtomicJson.write(path,previous,root.toString(),LIMIT);entries=candidate;followers=nextFollowers;legacy=VERSION;
|
||||
}
|
||||
@Override public synchronized List<CommunityTask.Follow> members(String id) {return followers.stream().filter(f->f.noticeId().equals(id)).toList();}
|
||||
@Override public synchronized List<Entry> following(Actor actor) {
|
||||
return followers.stream().filter(f->f.playerId().equals(actor.id())).sorted(Comparator.comparingLong(CommunityTask.Follow::since).reversed().thenComparing(CommunityTask.Follow::noticeId))
|
||||
.map(f->entries.get(f.noticeId())).filter(e->e!=null&&!e.status().equals("hidden")).toList();
|
||||
}
|
||||
@Override public synchronized void follow(Actor actor,String id,boolean join) throws Exception {
|
||||
Entry entry=entries.get(uuid(id));if(entry==null)fail("missing");
|
||||
CommunityTask.canFollow(entry,actor,join,members(id),(int)followers.stream().filter(f->f.playerId().equals(actor.id())).count());
|
||||
boolean exists=followers.stream().anyMatch(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
|
||||
if(exists==join)return;
|
||||
var next=new ArrayList<>(followers);
|
||||
if(join)next.add(new CommunityTask.Follow(id,actor.id(),actor.name(),clock.millis()));
|
||||
else next.removeIf(f->f.noticeId().equals(id)&&f.playerId().equals(actor.id()));
|
||||
persist(entries,next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import static fr.koka.sanctuary.community.Community.*;
|
||||
|
||||
/** JDBC stays on the community worker. No credentials or raw SQL errors reach players. */
|
||||
public final class SqlCommunityStore implements CommunityStore {
|
||||
private static final com.google.gson.Gson JSON=new com.google.gson.Gson();
|
||||
private final String url, user, password, serverId;
|
||||
private final Clock clock;
|
||||
public SqlCommunityStore(String url, String user, String password, String serverId) throws Exception {
|
||||
@@ -35,7 +36,7 @@ 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);
|
||||
@@ -69,14 +70,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 +89,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)));
|
||||
|
||||
@@ -2394,5 +2394,43 @@
|
||||
"sanctuary.pause.map": "World map",
|
||||
"sanctuary.pause.map_hint": "Open the map and explore",
|
||||
"sanctuary.community.more": "Load more",
|
||||
"sanctuary.community.browse": "All publications"
|
||||
"sanctuary.community.browse": "All publications",
|
||||
"sanctuary.community.photo": "Article photo",
|
||||
"sanctuary.community.photo_choose": "Choose a screenshot…",
|
||||
"sanctuary.community.photo_change": "Change photo…",
|
||||
"sanctuary.community.photo_assign": "Assign to article",
|
||||
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
|
||||
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
|
||||
"sanctuary.community.photo_uploading": "Uploading photo…",
|
||||
"sanctuary.community.screenshots": "Screenshots",
|
||||
"sanctuary.community.screenshots_back": "Back to screenshots",
|
||||
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
|
||||
"sanctuary.community.screenshots_error": "Could not read screenshots.",
|
||||
"sanctuary.community.photo_unavailable": "Photo unavailable",
|
||||
"sanctuary.community.task_configure": "Request and rewards",
|
||||
"sanctuary.community.task_summary": "%s materials · %s",
|
||||
"sanctuary.community.location_current": "Use my location",
|
||||
"sanctuary.community.location_remove": "Remove location",
|
||||
"sanctuary.community.location_value": "%s · %s, %s, %s",
|
||||
"sanctuary.community.materials": "Requested materials",
|
||||
"sanctuary.community.material_search": "Search materials",
|
||||
"sanctuary.community.selected_materials": "Descriptive chest · %s / %s",
|
||||
"sanctuary.community.quantity": "Quantity",
|
||||
"sanctuary.community.remove_material": "Remove",
|
||||
"sanctuary.community.audience.shared": "Multiple participants · shared reward",
|
||||
"sanctuary.community.audience.single": "One participant",
|
||||
"sanctuary.community.reward.none": "No reward",
|
||||
"sanctuary.community.reward.items": "Item reward",
|
||||
"sanctuary.community.reward.custom": "Custom reward",
|
||||
"sanctuary.community.rewards_choose": "Choose reward items",
|
||||
"sanctuary.community.exchange_help": "Descriptive tracking: players arrange exchanges and reward sharing themselves.",
|
||||
"sanctuary.community.followers": "%s follower(s)",
|
||||
"sanctuary.community.follow": "Follow request",
|
||||
"sanctuary.community.unfollow": "Unfollow",
|
||||
"sanctuary.community.followed": "Following request: %s",
|
||||
"sanctuary.community.task_invalid": "Check materials, quantities and reward.",
|
||||
"sanctuary.community.task_expired": "Request expired. Please publish again.",
|
||||
"sanctuary.community.own_notice": "You cannot follow your own request.",
|
||||
"sanctuary.community.follow_limit": "You already follow 16 requests.",
|
||||
"sanctuary.community.notice_full": "This request has no available places."
|
||||
}
|
||||
|
||||
@@ -2393,6 +2393,44 @@
|
||||
"sanctuary.community.reply_hint": "Écrire une réponse…",
|
||||
"sanctuary.pause.map": "Carte du monde",
|
||||
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
|
||||
"sanctuary.community.more": "Afficher la suite",
|
||||
"sanctuary.community.browse": "Toutes les publications"
|
||||
"sanctuary.community.more": "Charger la suite",
|
||||
"sanctuary.community.browse": "Toutes les publications",
|
||||
"sanctuary.community.photo": "Photo de l’article",
|
||||
"sanctuary.community.photo_choose": "Choisir une capture…",
|
||||
"sanctuary.community.photo_change": "Changer la photo…",
|
||||
"sanctuary.community.photo_assign": "Assigner à l’article",
|
||||
"sanctuary.community.photo_required": "Choisissez une photo pour publier cet article.",
|
||||
"sanctuary.community.photo_invalid": "Photo illisible ou trop grande.",
|
||||
"sanctuary.community.photo_uploading": "Envoi de la photo…",
|
||||
"sanctuary.community.screenshots": "Captures d’écran",
|
||||
"sanctuary.community.screenshots_back": "Retour aux captures",
|
||||
"sanctuary.community.screenshots_empty": "Aucune capture. Prenez une photo en jeu avec F2, puis revenez ici.",
|
||||
"sanctuary.community.screenshots_error": "Impossible de lire les captures.",
|
||||
"sanctuary.community.photo_unavailable": "Photo indisponible",
|
||||
"sanctuary.community.task_configure": "Demande et récompenses",
|
||||
"sanctuary.community.task_summary": "%s matériaux · %s",
|
||||
"sanctuary.community.location_current": "Utiliser ma position",
|
||||
"sanctuary.community.location_remove": "Retirer la localisation",
|
||||
"sanctuary.community.location_value": "%s · %s, %s, %s",
|
||||
"sanctuary.community.materials": "Matériaux demandés",
|
||||
"sanctuary.community.material_search": "Rechercher un matériau",
|
||||
"sanctuary.community.selected_materials": "Coffre descriptif · %s / %s",
|
||||
"sanctuary.community.quantity": "Quantité",
|
||||
"sanctuary.community.remove_material": "Retirer",
|
||||
"sanctuary.community.audience.shared": "Plusieurs participants · partage",
|
||||
"sanctuary.community.audience.single": "Un seul participant",
|
||||
"sanctuary.community.reward.none": "Sans récompense",
|
||||
"sanctuary.community.reward.items": "Récompense en objets",
|
||||
"sanctuary.community.reward.custom": "Récompense personnalisée",
|
||||
"sanctuary.community.rewards_choose": "Choisir les objets proposés",
|
||||
"sanctuary.community.exchange_help": "Suivi descriptif : les joueurs organisent les échanges et le partage des récompenses entre eux.",
|
||||
"sanctuary.community.followers": "%s participant(s)",
|
||||
"sanctuary.community.follow": "Suivre la demande",
|
||||
"sanctuary.community.unfollow": "Ne plus suivre",
|
||||
"sanctuary.community.followed": "Demande suivie : %s",
|
||||
"sanctuary.community.task_invalid": "Vérifiez les matériaux, les quantités et la récompense.",
|
||||
"sanctuary.community.task_expired": "La demande a expiré. Réessayez de publier.",
|
||||
"sanctuary.community.own_notice": "Vous ne pouvez pas suivre votre propre demande.",
|
||||
"sanctuary.community.follow_limit": "Vous suivez déjà 16 demandes.",
|
||||
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Stop both applications and back up the database first. Apply once to schema v1.
|
||||
ALTER TABLE sanctuary_community_entries ADD COLUMN photo MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NULL;
|
||||
UPDATE sanctuary_community_schema SET version=2 WHERE id=1 AND version=1;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Stop both writers, back up, and apply once to schema v2.
|
||||
ALTER TABLE sanctuary_community_entries ADD COLUMN task TEXT NULL;
|
||||
CREATE TABLE sanctuary_community_subscribers (
|
||||
server_id CHAR(36) NOT NULL, player_id CHAR(36) NOT NULL,
|
||||
PRIMARY KEY(server_id,player_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
CREATE TABLE sanctuary_community_followers (
|
||||
server_id CHAR(36) NOT NULL, notice_id CHAR(36) NOT NULL,
|
||||
player_id CHAR(36) NOT NULL, player_name VARCHAR(64) NOT NULL, subscribed_at BIGINT NOT NULL,
|
||||
PRIMARY KEY(server_id,notice_id,player_id),
|
||||
KEY community_followed(server_id,player_id,subscribed_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
UPDATE sanctuary_community_schema SET version=3 WHERE id=1 AND version=2;
|
||||
@@ -15,9 +15,10 @@ public final class CommunityInterop {
|
||||
JsonObject fixture;
|
||||
if(phase.equals("seed")) {
|
||||
fixture=new JsonObject();
|
||||
for(String key:new String[]{"server","player","article"})fixture.addProperty(key,UUID.randomUUID().toString());
|
||||
for(String key:new String[]{"server","player","article","notice"})fixture.addProperty(key,UUID.randomUUID().toString());
|
||||
var store=new SqlCommunityStore(url,"root","",fixture.get("server").getAsString());
|
||||
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0));
|
||||
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0,CommunitySmoke.PHOTO));
|
||||
store.change(new Actor(UUID.randomUUID().toString(),"Voisin",false),new Change("create",fixture.get("notice").getAsString(),"","notice","need","Le pont","Bois demandé",0,null,new CommunityTask(new CommunityTask.Location("minecraft:overworld",12,64,-4),java.util.List.of(new CommunityTask.Material("minecraft:oak_log",64)),"single","custom",java.util.List.of(),"Un repas")));
|
||||
Files.writeString(file,json.toJson(fixture));
|
||||
} else {
|
||||
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
|
||||
@@ -30,8 +31,14 @@ public final class CommunityInterop {
|
||||
CommunitySmoke.refuse("link_expired",()->store.link(actor,code));
|
||||
CommunitySmoke.refuse("link_expired",()->new SqlCommunityStore(url,"root","",UUID.randomUUID().toString()).link(actor,code));
|
||||
} else if(phase.equals("verify")) {
|
||||
var notice=store.thread(fixture.get("notice").getAsString(),0).entry();
|
||||
CommunitySmoke.check(store.following(actor).stream().anyMatch(e->e.id().equals(notice.id())),"Website subscription visible in Java");
|
||||
var webNotice=store.thread(fixture.get("web_notice").getAsString(),0).entry();
|
||||
CommunitySmoke.check(webNotice.task().materials().getFirst().count()==128 && webNotice.task().rewards().getFirst().item().equals("minecraft:diamond"),"Website task descriptors read in Java");
|
||||
var thread=store.thread(fixture.get("article").getAsString(),0);
|
||||
CommunitySmoke.check(thread.entry().revision()==2 && thread.entry().body().equals("Modifié depuis le site 🌳"),"Website edit visible in Java");
|
||||
CommunityPhoto.validate(store.thread(fixture.get("web_article").getAsString(),0).entry().photo());
|
||||
CommunitySmoke.check(!thread.entry().photo().isEmpty(),"Photo preserved by website edit");
|
||||
CommunitySmoke.check(thread.replies().entries().size()==1 && thread.replies().entries().getFirst().body().equals("Réponse depuis le site"),"Website reply visible in Java");
|
||||
CommunitySmoke.refuse("conflict",()->store.change(actor,new Change("edit",thread.entry().id(),"","article","info","Depuis Minecraft","Ancien brouillon",1)));
|
||||
} else throw new IllegalArgumentException(phase);
|
||||
|
||||
@@ -8,11 +8,48 @@ 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 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 +74,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);
|
||||
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 +102,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);
|
||||
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 +121,28 @@ public final class CommunitySmoke {
|
||||
refuse("already_linked",()->sql.link(owner,reassignment));
|
||||
System.out.println("COMMUNITY154_SQL_PASS");
|
||||
}
|
||||
String operation=UUID.randomUUID().toString();
|
||||
var transfer=new CommunityPhoto.Transfer(operation,2,1000);
|
||||
transfer.append(operation,0,2,"abc",1000);
|
||||
refuse("photo_invalid",()->transfer.append(operation,0,2,"abc",1001));
|
||||
refuse("photo_required",()->transfer.value(operation,1001));
|
||||
transfer.append(operation,1,2,"def",1002);
|
||||
check(transfer.value(operation,1003).equals("abcdef"),"Ordered transfer");
|
||||
refuse("photo_required",()->transfer.value(UUID.randomUUID().toString(),1003));
|
||||
refuse("photo_required",()->transfer.value(operation,121001));
|
||||
for(int oldVersion:List.of(1,2)) {
|
||||
Path legacy=dir.resolve("legacy"+oldVersion+".json");
|
||||
var root=new com.google.gson.JsonObject();root.addProperty("version",oldVersion);root.addProperty("serverId",server);
|
||||
var entries=new com.google.gson.JsonArray();var old=new com.google.gson.Gson().toJsonTree(article).getAsJsonObject();old.remove("photo");entries.add(old);root.add("entries",entries);
|
||||
String before=root.toString();Files.writeString(legacy,before);
|
||||
var migrated=new FileCommunityStore(legacy,server);
|
||||
check(migrated.thread(article.id(),0).entry().photo().isEmpty(),"Old text article readable");
|
||||
refuse("photo_required",()->migrated.change(owner,action("edit",migrated.thread(article.id(),0).entry())));
|
||||
migrated.change(owner,new Change("edit",article.id(),"","article","info",article.title(),article.body(),article.revision(),PHOTO));
|
||||
check(Files.readString(dir.resolve("legacy"+oldVersion+".json.v"+oldVersion+".bak")).equals(before),"Exact migration backup");
|
||||
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
|
||||
}
|
||||
System.out.println("COMMUNITY157_PHOTOS_PASS");
|
||||
System.out.println("COMMUNITY154_FILE_PASS");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.156"
|
||||
version = "beta.158"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user