Compare commits

..
3 Commits
Author SHA1 Message Date
koka 9791d178ab beta.157: require article photos with a scrollable screenshot gallery
Build Sanctuary / build (push) Waiting to run
2026-09-22 22:43:19 +02:00
koka 32158e2e05 Déplace la reprise du jeu sous la colonne communautaire (beta.156)
Build Sanctuary / build (push) Waiting to run
2026-09-22 21:34:41 +02:00
koka 3bfb3cb251 Refonte du menu pause et panneaux communautaires défilants (beta.155)
Build Sanctuary / build (push) Waiting to run
2026-09-22 21:29:44 +02:00
36 changed files with 1257 additions and 172 deletions
+18
View File
@@ -1,5 +1,23 @@
# Sanctuary
## beta.157 — photo obligatoire dans la Gazette (pack local)
Galerie de captures défilante, aperçu et assignation à un article. Publication
avec photo obligatoire, stockage fichier ou MariaDB partagé avec le site.
[Contrat de migration et vérifications](docs/gazette-photos-beta157.md).
## beta.156 — reprendre la partie sous la colonne communautaire (pack local)
Le bouton Reprendre la partie est aligné sous la Gazette et le tableau
d’affichage, en bas à droite. [Vérifications](docs/pause-resume-beta156.md).
## beta.155 — nouvelle structure du menu pause (pack local)
Navigation Sanctuary à gauche, carte centrale, Gazette et demandes défilantes
à droite. Date, intendance et heure en en-tête ; options et sortie en bas de la colonne
gauche, reprise de la partie en bas au centre.
[Contrat et vérifications](docs/pause-redesign-beta155.md).
## beta.154 — Gazette et tableau communautaire (pack local)
Panneaux gauche/droite du menu pause, intendance, publication, réponses,
+12
View File
@@ -1,5 +1,8 @@
# Communauté Sanctuary — contrat v1
> Depuis beta.157, le schéma actif est la v2 avec photo obligatoire pour les nouveaux
> articles. Voir la section « Évolution v2 » en fin de document avant installation.
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,12 @@ 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.
+96
View File
@@ -0,0 +1,96 @@
# beta.157 — photo obligatoire et galerie de captures
## Contrat de migration communautaire v2
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
leur prochaine modification exige une photo. Avis, intendance et réponses restent
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
référence à un chemin local, d'URL distante ou de média orphelin persistant.
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
de restaurer la copie v1 et perd les changements communautaires postérieurs.
Ne pas restaurer pendant que le serveur tourne.
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
par la préparation de cette livraison.
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
seul le détail d'un article les contient. Le plafond des réponses
passe à 262 144 caractères pour une photo et une discussion complète ; les
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
anciens clients ne peuvent plus créer d'article sans photo.
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
Les captures illisibles restent visibles mais désactivées. Limites sources :
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
du dossier `screenshots` de son instance.
## Vérifications
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
désactivé sans photo, envoi fragmenté, photo visible et relecture après
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
copie de migration exacte, fragments hors ordre/incomplets/expirés.
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
anciennes publications et conservation après modification.
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
modification et réponse Web relues en Java, article/photo du site décodés par
Java. Migration Laravel et alternative SQL testées sur bases de développement.
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
indépendants, pagination, article et avis longs, réponse, options et reprise :
réussie avec les articles munis d'une photo.
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
client natif du menu pause), 2 min 56 s.
Les captures d'essai sont des mires générées dans une instance de développement,
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
`9626e95`, sans push ni publication. Logs de développement :
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
`build/photos157-final-build.log`.
## Artefacts locaux
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
Reçu complet : `build/photos157-validation.json`.
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
+114
View File
@@ -0,0 +1,114 @@
# PAUSE-155 — une entrée Sanctuary, trois espaces
Branche `codex/pause-redesign-beta155`, livraison locale `beta.155`.
Suite de [COMMUNITY-154](community-beta154.md).
## Résultat attendu
Remplacer l'ancienne grille du menu pause par trois espaces sans fond ou
séparateurs imposés à chaque espace :
- En-tête : date locale à gauche, dernier message d'intendance au centre,
heure locale à droite. Le message ouvre directement sa publication.
- Corps : navigation permanente Découverte, Monde, Progression, Habitant,
Faction et Batailles à gauche, tous espacés de 4 pixels comme les boutons
d’options. Carte personnelle au centre. Gazette au-dessus du tableau d'affichage à droite.
- Bas de la colonne gauche : Options, Options du monde, puis Sauvegarder et
quitter, empilés dans cet ordre et alignés avec les six boutons Sanctuary.
Reprendre la partie se trouve seul tout en bas de la colonne centrale,
sous la carte. Les actions natives Minecraft sont réutilisées, y compris
Déconnexion en multijoueur. À grande échelle d'interface, les six boutons
de navigation deviennent plus compacts pour préserver cette séparation.
La Gazette présente des boutons-titres avec la tête de l’auteur dans un carré
de 20 pixels à gauche, sur la même ligne ; le nom reste disponible au survol. Les demandes prennent la forme de
cartes encadrées avec aperçu, auteur, catégorie et état. Chaque zone possède
son propre défilement à la molette et sa barre de défilement. Le bouton +
ouvre la rédaction, l'en-tête ouvre la liste complète. « Afficher la suite »
ajoute la page suivante sans quitter le menu ni remettre le défilement à zéro.
La lecture des publications longues et des conversations utilise le
conteneur défilant natif existant.
## Périmètre technique
La carte utilise exclusivement les relevés personnels de l'atlas existant,
sans chargement ou génération de chunks. Elle respecte le déblocage de
l'atlas et les restrictions de dimension. Un clic ouvre la carte détaillée.
Le serveur solo transmet les relevés existants pendant la pause, par lots bornés,
sans observer de nouveaux blocs ni avancer la simulation. La limite de fréquence
accepte aussi le temps réel lorsque les ticks du monde sont arrêtés.
La texture et le flux réseau sont libérés en quittant le menu ; les réponses
tardives ne sont acceptées que par l'écran courant et son jeton.
Les publications utilisent les mêmes opérations paginées du contrat v1,
en fichier comme en MariaDB. Aucune migration, table ou modification du site.
L'actualisation des premières pages intervient lorsque leur défilement est en
haut ; une liste étendue reste stable jusqu'à sa réouverture.
Les textes d'interface sont disponibles en français et anglais.
La sélection d'un screenshot pour la Gazette reste l'étape suivante.
Les visages des demandes du tableau restent différés ; ceux de la Gazette
et des conversations sont présents. Les futures entrées
économiques ne sont pas des boutons factices.
## Vérifications
Environnement : macOS Apple M1, Java Temurin 25, Minecraft 26.3, Fabric Loader
0.19.5, Fabric API 0.160.5+26.3, Vulkan/MoltenVK 1.4.2. Base de développement
MariaDB 12.3.3, distincte des données personnelles et du site public.
- Essais natifs Vulkan réussis dans les deux modes : fichier et MariaDB.
Français et anglais, GUI 2/3/4 à 1280 × 960 pixels (jusqu'à 320 × 240 unités
d'interface), contrôles contenus dans l'écran.
- Six vrais boutons Sanctuary ; espacement uniforme de 4 pixels en haut et
en bas ; avatars d’articles alignés à gauche des titres.
Défilements indépendants à la molette, ajout de la treizième publication par
pagination sans retour en haut, lecture jusqu'au bas des longs articles et
demandes, réponse publiée et relue.
- Options et Options du monde natives, reprise du jeu, ouverture de la carte
détaillée puis retour au menu. Carte personnelle reçue en plusieurs lots
pendant la pause ; l'horloge de simulation du monde reste immobile.
- Logs : `build/pause155-client-file.log`, `build/pause155-client-database.log`.
Captures : `build/pause155-{file,database}-screenshots/`, aperçu
`build/pause155-preview.png`. Les articles et le terrain de ces captures
proviennent exclusivement d'un monde de test plat et de données fictives.
`./gradlew check build` a terminé ses 252 GameTests : 229 réussites et les
23 échecs déjà recensés dans beta.154 (mêmes identifiants, aucun ajout ni
retrait). La commande échoue donc ; ce n'est pas un contrôle global vert.
Log : `build/pause155-check-build.log`, comparaison :
`build/pause155-server-failures.json` / `build/community-server-failures.json`.
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit :
compilation, contrôles hors GameTests et assemblage. Les derniers ajustements
visuels de disposition sont ensuite vérifiés par les scénarios client natifs
et un nouvel assemblage des artefacts, sans réexécuter les 252 GameTests.
Log : `build/pause155-build-pack.log`.
Aucun déploiement personnel ni publication du canal packwiz.
Commande des essais natifs :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryQuickTests=true -PsanctuaryPause155ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan
```
Le scénario MariaDB utilise les variables de développement documentées dans
[COMMUNITY-154](community-beta154.md). Le schéma reste le contrat v1.
La sélection de screenshots et la validation Windows restent ouvertes.
## Artefacts locaux vérifiés
Assemblage final réussi avec `./gradlew build assemblePack -x :sanctuary:check`,
après réussite des essais natifs de la disposition finale dans les deux modes.
Export packwiz réussi. ZIP, version Fabric/Minecraft, JAR unique dans le MRpack,
pilote MariaDB embarqué et absence des classes de test vérifiés. Les 29 fichiers
shader sont identiques octet pour octet à beta.151. Les anciens JAR et le pack
beta.154 sont conservés, leurs empreintes vérifiées.
Reçu : `build/pause155-artifacts.json`. Logs : `build/pause155-final-assemble.log`
et `build/pause155-export.log`.
- `mods/sanctuary/build/libs/sanctuary-beta.155.jar` — SHA-256 `e8ba34ddd2726eb48fcea2b56a6f5a2e0262354553378c5bc25ca6f5061d32dc`.
- `build/Sanctuary-beta.155.mrpack` — SHA-256 `11be47c07edbb8180b4d6911a1eff7656ba2ce4168d35b96096519a2491e899f`.
+33
View File
@@ -0,0 +1,33 @@
# PAUSE-156 — reprendre la partie sous la colonne droite
Branche `codex/pause-resume-right-beta156`, livraison locale `beta.156`.
Suite de [PAUSE-155](pause-redesign-beta155.md).
Le bouton natif Reprendre la partie se trouve en bas à droite, sous la colonne
Gazette / tableau, avec le même alignement et la même largeur. Il conserve son
action native et les libellés français / anglais de Minecraft.
La modification concerne uniquement sa géométrie. Les options restent en bas
à gauche ; les publications, le stockage, la carte et les shaders conservent
leur fonctionnement de beta.155. Aucune migration ni installation personnelle.
## Vérifications
Le scénario client Vulkan existant passe en français et anglais, GUI 2/3/4 à
1280 × 960 : alignement du bouton avec la colonne droite, largeur identique,
absence de chevauchement avec le tableau, reprise effective du jeu. Les
interactions communautaires du scénario passent également en mode fichier.
Log : `build/pause156-client.log`, aperçu `build/pause156-preview.png`.
`./gradlew check build assemblePack -x :sanctuary:runGameTest` réussit.
Export packwiz, intégrité ZIP, versions du mod / pack et contenu du JAR
embarqué vérifiés. Les shaders sont inchangés et les artefacts beta.155
conservés avec leurs empreintes. Logs : `build/pause156-build-pack.log`,
`build/pause156-export.log`, reçu `build/pause156-artifacts.json`.
La suite serveur complète de beta.155 compte 229 réussites / 252, avec les
23 échecs historiques. Elle n’est pas relancée pour ce déplacement de bouton.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.156.jar` — SHA-256 `c325a757cff6063fe3702e755a57a3abc13ad86e071762354a3c449504d51c6f`.
- `build/Sanctuary-beta.156.mrpack` — SHA-256 `0537273dbfecb071d481f3f22982fbbb9a8e8a75581e38128ded87926ee699d0`.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.154
pack_version=beta.154
mod_version=beta.157
pack_version=beta.157
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+6
View File
@@ -258,6 +258,12 @@ tasks.named('processGametestResources') {
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause155ClientTests', pause155Client)
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
def community154Client = providers.gradleProperty('sanctuaryCommunity154ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryCommunity154ClientTests', community154Client)
if (community154Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Community154ClientChecks') }
@@ -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,10 +80,11 @@ 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)); }
static void press(Screen s,String key) { widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)).findFirst().orElseThrow(() -> new AssertionError("Missing "+key)).onPress(enter()); }
static void press(Screen s,String key) { widgets(s).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)).findFirst().orElseThrow(() -> new AssertionError("Missing "+key)).onPress(enter()); }
static EditBox edit(Screen s) { return widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow(); }
static MultiLineEditBox body(Screen s) { return widgets(s).filter(MultiLineEditBox.class::isInstance).map(MultiLineEditBox.class::cast).findFirst().orElseThrow(); }
static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN,0,0); }
@@ -0,0 +1,109 @@
package fr.koka.sanctuary.client;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
public final class Pause155ClientChecks implements FabricClientGameTest {
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
java.nio.file.Files.createDirectories(path.getParent());java.nio.file.Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e) {throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen s) {
Starter039TestSupport.selectFirst(s);s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());press(s,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Pause155 "+UUID.randomUUID());s.setSeed("155");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active() && m.player!=null && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.setExperienceLevels(20);
check(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Atlas unlocked");ProgressionService.send(p,"");
for(int dz=-2;dz<=2;dz++)for(int dx=-2;dx<=2;dx++) {
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(p.level(),p.chunkPosition().x()+dx,p.chunkPosition().z()+dz);
check(tile!=null,"Loaded fixture tile");fr.koka.sanctuary.atlas.AtlasService.observations(p).observe(tile);
}
try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
} catch(Exception e) {throw new AssertionError(e);}
});
c.waitFor(m->ProgressionClient.progress.atlas(),200);c.waitTicks(40);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("Gazette — ")).count()==10 && widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("La passerelle — ")).count()==10 && !CommunityClient.pending(),400);
c.waitFor(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>4096,400);
c.runOnClient(m->{
var s=m.gui.screen();
check(s instanceof PauseAtlasAccess,"Pause atlas access");
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.history","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=40&&b.getY()<s.height-76).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
check(navigation.size()==6,"Six real navigation buttons");
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
var options=nativeButton(s,"menu.options");var worldOptions=nativeButton(s,"options.worldOptions.button");var quit=nativeButton(s,"menu.returnToMenu");var resume=nativeButton(s,"menu.returnToGame");
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-8,"Options and quit stacked at bottom left");
var feed=areas(s).getFirst();
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
for(var e:s.children())if(e instanceof Button b && b.getX()==8 && b.getY()<options.getY())check(b.getBottom()<options.getY(),"Navigation does not overlap options");
check(!has(s,"gui.advancements") && !has(s,"gui.stats") && !has(s,"menu.game"),"Old grid removed");
for(var e:s.children())if(e instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Widget bounds "+w.getClass().getSimpleName()+" "+w.getX()+","+w.getY()+" "+w.getWidth()+"x"+w.getHeight()+" / "+s.width+"x"+s.height);
var faces=widgets(s).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).toList();
check(faces.size()==10,"Each Gazette article has a face");
var first=firstPost(s,"Gazette — ");check(faces.getFirst().getY()==first.getY() && faces.getFirst().getRight()+5==first.getX(),"Face is left of the title on the same row");
var areas=areas(s);check(areas.size()==2,"Two independent feeds");
double other=areas.get(1).scrollAmount();check(s.mouseScrolled(areas.get(0).getX()+5,areas.get(0).getY()+5,0,-5),"Gazette wheel handled");check(areas.get(0).scrollAmount()>0&&areas.get(1).scrollAmount()==other,"Gazette scroll isolated");
check(s.mouseScrolled(areas.get(1).getX()+5,areas.get(1).getY()+5,0,-5),"Notice wheel handled");check(areas.get(1).scrollAmount()>0,"Notices scroll");
areas.forEach(a->a.setScrollAmount(0));
});
if(locale.equals("fr_fr") && scale==2) {
var time=new java.util.concurrent.atomic.AtomicLong();
world.getServer().runOnServer(s->{check(((net.minecraft.client.server.IntegratedServer)s).isPaused(),"Solo remains paused");time.set(s.overworld().getGameTime());});
c.waitTicks(30);world.getServer().runOnServer(s->check(s.overworld().getGameTime()==time.get(),"Reading atlas does not advance world"));
}
c.takeScreenshot("pause155-"+locale+"-"+scale+(database?"-database":"-file"));
}
c.runOnClient(m->{var areas=areas(m.gui.screen());areas.getFirst().setScrollAmount(areas.getFirst().maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith("Gazette — ")).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(areas(m.gui.screen()).getFirst().scrollAmount()>0,"Appending preserves scroll");firstPost(m.gui.screen(),"Gazette — ").onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),300);
c.runOnClient(m->{var a=areas(m.gui.screen()).getFirst();check(a.maxScrollAmount()>300,"Long article scrolls");m.gui.screen().mouseScrolled(a.getX()+5,a.getY()+5,0,-1000);check(a.scrollAmount()==a.maxScrollAmount(),"Reach article end and reply");});
c.takeScreenshot("pause155-long-article");
c.runOnClient(m->{body(m.gui.screen()).setValue("Présent pour le marché !");press(m.gui.screen(),"sanctuary.community.reply");});
c.waitFor(m->text(m.gui.screen(),"Présent pour le marché !")&&!CommunityClient.pending(),300);
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));
c.waitFor(m->widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->w.getMessage().getString().startsWith("La passerelle — ")),300);
c.runOnClient(m->firstPost(m.gui.screen(),"La passerelle — ").onPress(enter()));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),300);
c.runOnClient(m->{var a=areas(m.gui.screen()).getFirst();check(a.maxScrollAmount()>300,"Long notice scrolls");m.gui.screen().mouseScrolled(a.getX()+5,a.getY()+5,0,-1000);check(a.scrollAmount()==a.maxScrollAmount(),"Reach notice end");m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>4096,400);
c.runOnClient(m->{check(((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().knownPixels()>0,"Paused map renders personal surveys");press(m.gui.screen(),"menu.options");check(m.gui.screen() instanceof net.minecraft.client.gui.screens.options.OptionsScreen,"Native options");m.gui.screen().onClose();press(m.gui.screen(),"options.worldOptions.button");check(m.gui.screen() instanceof WorldOptionsScreen,"Native world options");m.gui.screen().onClose();press(m.gui.screen(),"menu.returnToGame");check(m.gui.screen()==null,"Resume game");});
c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(25);
c.runOnClient(m->((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap().onPress(enter()));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen,100);
c.runOnClient(m->m.gui.screen().onClose());c.waitTicks(25);
c.runOnClient(m->{check(m.gui.screen() instanceof PauseScreen,"Atlas returns to pause");m.gui.setScreen(null);});
System.out.println("PAUSE155_CLIENT_PASS mode="+(database?"database":"file")+" FR/EN scales 2/3/4, independent wheel scrolling, pagination, long article/notice, reply, native footer, atlas return");
} finally {intro.set(false);}
}
private static Button nativeButton(Screen s,String key) {return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->CommunityPausePanels.key(b).equals(key)).findFirst().orElseThrow();}
private static List<AbstractScrollArea> areas(Screen s) {return widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).toList();}
private static AbstractButton firstPost(Screen s,String prefix) {return widgets(s).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow();}
}
@@ -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(CommunityPhotoWidget.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();}
}
@@ -19,7 +19,7 @@ public final class AtlasService {
private static final Map<MinecraftServer,Map<UUID,State>> STATES=new WeakHashMap<>();
private static final int[][] OFFSETS=offsets();
private static final class State {
AtlasStore store;boolean failed;int cursor,lastRequest=-100,lastSave,saveLayer;Job job;
AtlasStore store;boolean failed;int cursor,lastRequest=-100,lastSave,saveLayer;long lastRequestNanos;Job job;
final Map<Integer,AtlasStore> layers=new LinkedHashMap<>();
}
private record Job(AtlasPayloads.Request request,ArrayDeque<AtlasStore.Key> remaining) { }
@@ -75,8 +75,9 @@ public final class AtlasService {
if (request.token()==0) {state.job=null;return;}
int tick=player.level().getServer().getTickCount();
state.job=null;
if (tick-state.lastRequest<10) {reply(player,request,true,true,"retry",List.of());return;}
state.lastRequest=tick;
long now=System.nanoTime();
if (tick-state.lastRequest<10 && now-state.lastRequestNanos<500_000_000L) {reply(player,request,true,true,"retry",List.of());return;}
state.lastRequest=tick;state.lastRequestNanos=now;
if (state.failed) {reply(player,request,true,true,"storage_invalid",List.of());return;}
if (!canRead(player,request.operator())) {
reply(player,request,true,true,player.level().dimension()!=Level.OVERWORLD ? "dimension" : request.operator() ? "denied" : "locked",List.of());return;
@@ -123,24 +124,35 @@ public final class AtlasService {
catch (Exception error) {state.failed=true;state.job=null;SanctuaryMod.LOGGER.error("Could not save atlas layer (file preserved)",error);}
state.lastSave=tick;saved=true;
}
var job=state.job;
if (job==null) continue;
if (!canRead(player,job.request.operator())) {
reply(player,job.request,true,true,"denied",List.of());state.job=null;continue;
}
var tiles=new ArrayList<AtlasStore.Tile>();
int budget=job.request.operator() ? 4 : AtlasPayloads.BATCH;
while (budget-->0 && !job.remaining.isEmpty()) {
var key=job.remaining.removeFirst();
var tile=job.request.operator() ? AtlasSampler.sample(player.level(),key.x(),key.z(),job.request.altitude())
: state.layers.get(job.request.altitude()).get(key.x(),key.z());
if (tile!=null) tiles.add(tile);
}
boolean done=job.remaining.isEmpty();
reply(player,job.request,false,done,state.layers.get(job.request.altitude()).full() ? "full" : "ready",tiles);
if (done) state.job=null;
stream(player,state);
}
}
/** The paused integrated server may send stored personal tiles, never sample or advance the world. */
public static void streamPaused(MinecraftServer server) {
var states=STATES.get(server);if(states==null)return;
for(var player:server.getPlayerList().getPlayers()) {
var state=states.get(player.getUUID());
if(state!=null && !state.failed && state.job!=null && !state.job.request.operator())stream(player,state);
}
}
private static void stream(ServerPlayer player,State state) {
var job=state.job;
if (job==null) return;
if (!canRead(player,job.request.operator())) {
reply(player,job.request,true,true,"denied",List.of());state.job=null;return;
}
var tiles=new ArrayList<AtlasStore.Tile>();
int budget=job.request.operator() ? 4 : AtlasPayloads.BATCH;
while (budget-->0 && !job.remaining.isEmpty()) {
var key=job.remaining.removeFirst();
var tile=job.request.operator() ? AtlasSampler.sample(player.level(),key.x(),key.z(),job.request.altitude())
: state.layers.get(job.request.altitude()).get(key.x(),key.z());
if (tile!=null) tiles.add(tile);
}
boolean done=job.remaining.isEmpty();
reply(player,job.request,false,done,state.layers.get(job.request.altitude()).full() ? "full" : "ready",tiles);
if (done) state.job=null;
}
private static void reply(ServerPlayer player,AtlasPayloads.Request request,boolean reset,boolean done,String status,List<AtlasStore.Tile> tiles) {
if (ServerPlayNetworking.canSend(player,AtlasPayloads.Response.TYPE)) ServerPlayNetworking.send(player,
new AtlasPayloads.Response(request.token(),reset,done,canOperate(player),request.operator(),status,tiles));
@@ -16,6 +16,7 @@ public final class AtlasClient {
}));
ClientPlayNetworking.registerGlobalReceiver(AtlasPayloads.Response.TYPE,(p,c) -> c.client().execute(() -> {
if (c.client().gui.screen() instanceof SanctuaryAtlasScreen screen) screen.accept(p);
else if (c.client().gui.screen() instanceof PauseAtlasAccess pause && pause.sanctuary$pauseMap()!=null) pause.sanctuary$pauseMap().accept(p);
}));
}
public static int nextToken() {sequence=sequence==Integer.MAX_VALUE ? 1 : sequence+1;return sequence;}
@@ -19,7 +19,7 @@ public final class CommunityClient {
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 = ""; }
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); }
@@ -1,79 +1,88 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.CommunityClient.*;
public final class CommunityPausePanels {
/** Three spatial regions, with independent community feeds and native exit/options actions. */
public final class CommunityPausePanels implements AutoCloseable {
private final Screen screen;
private final List<Runnable> updates = new ArrayList<>();
private final PauseCommunityFeed articles, notices;
private final StringWidget date, clock;
private final PauseTextButton bulletin;
private final PauseAtlasWidget map;
private int ticks;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add) {
private boolean closed;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
this.screen=screen;
int side=Math.min(220,(screen.width-244)/2);
if (side<145 || screen.height<235) {
int w=Math.max(40,(screen.width-28)/3);
int x=10;
for (String kind:new String[]{"article","bulletin","notice"}) {
String target=kind;
add.accept(Button.builder(text(kind),b->open(screen,target,"",false)).bounds(x,6,w,20).build()); x+=w+4;
}
} else {
var bulletin=Button.builder(text("bulletin"),b->open(screen,"bulletin","",false)).bounds(12,7,screen.width-24,20).build();
add.accept(bulletin);
updates.add(()->{
var items=items("bulletins");
Component message=text("bulletin").append(" · ").append(items.isEmpty()?text("bulletin_empty"):Component.literal(str(items.get(0).getAsJsonObject(),"title")));
if (!overviewError.isEmpty()) message=message.copy().append(" · ").append(text(overviewError));
else if (stale) message=message.copy().append(" · ").append(text("cached"));
bulletin.setMessage(message);
bulletin.setTooltip(Tooltip.create(items.isEmpty()?text("bulletin_empty"):Component.literal(str(items.get(0).getAsJsonObject(),"body"))));
});
panel(add,12,side,"article","articles"); panel(add,screen.width-side-12,side,"notice","notices");
var font=Minecraft.getInstance().font;
int margin=8,gap=10,top=40,bottom=screen.height-38;
int left=Math.min(132,Math.max(76,screen.width/5)),right=Math.min(244,Math.max(104,screen.width*31/100));
int centerX=margin+left+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
date=new StringWidget(margin,8,left,20,Component.empty(),font);
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
add.accept(date);add.accept(clock);
int headerSide=Math.max(84,screen.width/5);
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->{
var list=items("bulletins");open(screen,"bulletin",list.isEmpty()?"":str(list.get(0).getAsJsonObject(),"id"),false);
});
add.accept(bulletin);
String[] sections={"blocks","history","progress","identity"};
int optionsTop=screen.height-76;
int navHeight=Math.min(20,Math.max(16,(optionsTop-top-28)/6));
int step=navHeight+4;
for(int i=0;i<sections.length;i++) {
String target=sections[i];
add.accept(Button.builder(fr.koka.sanctuary.progression.ProgressionService.label(target),b->ProgressionClient.open(screen,target))
.bounds(margin,top+i*step,left,navHeight).build());
}
tick();
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+4*step,left,navHeight).build());
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
int feedHeight=(bottom-top-gap)/2;
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
place(nativeButtons,add,"menu.options",margin,optionsTop,left);
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+24,left);
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
button.setRectangle(left,20,margin,optionsTop+48);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
}
updateHeader();
}
public static String key(Button button) { return button.getMessage().getContents() instanceof TranslatableContents t?t.getKey():""; }
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width) {
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,20,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
}
private static JsonArray items(String key) { return overview.has(key)?overview.getAsJsonArray(key):new JsonArray(); }
private void panel(Consumer<AbstractWidget> add,int x,int w,String kind,String collection) {
int top=53,h=screen.height-top-12;
add.accept(new Panel(x,top,w,h,collection));
add.accept(Button.builder(text(kind),b->open(screen,kind,"",false)).bounds(x+6,top+7,w-12,20).build());
int count=Math.max(1,Math.min(3,(h-72)/43));
for(int i=0;i<count;i++) {
int index=i;
var button=Button.builder(text("loading"),b->{var list=items(collection);if(index<list.size())open(screen,kind,str(list.get(index).getAsJsonObject(),"id"),false);}).bounds(x+6,top+36+i*43,w-12,20).build();
updates.add(()->{
var list=items(collection); button.active=index<list.size(); button.visible=index<list.size() || index==0;
button.setMessage(index<list.size()?Component.literal((flag(list.get(index).getAsJsonObject(),"pinned")?"★ ":"")+str(list.get(index).getAsJsonObject(),"title")):text(overview.isEmpty()?(overviewError.isEmpty()?"loading":overviewError):"empty"));
}); add.accept(button);
}
add.accept(Button.builder(text("publish"),b->open(screen,kind,"",true)).bounds(x+6,top+h-27,w-12,20).build());
private void updateHeader() {
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
var now=ZonedDateTime.now();
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM",locale).format(now)));
clock.setMessage(Component.literal(DateTimeFormatter.ofPattern("HH:mm",locale).format(now)));
clock.setX(screen.width-8-Minecraft.getInstance().font.width(clock.getMessage()));clock.setWidth(Minecraft.getInstance().font.width(clock.getMessage()));
var list=items("bulletins");
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
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();
}
public void tick() {
for(var update:updates)update.run();
if(!pending() && (ticks++%100==0)) overview(screen);
}
private static final class Panel extends AbstractWidget {
private final String collection;
private final Map<String,net.minecraft.world.item.component.ResolvableProfile> profiles=new HashMap<>();
Panel(int x,int y,int w,int h,String collection) { super(x,y,w,h,Component.empty()); this.collection=collection; active=false; }
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY(); g.fill(x,y,x+width,y+height,0xCE202020); g.fill(x,y,x+width,y+1,0xFF777777);
var font=Minecraft.getInstance().font;var list=items(collection);int count=Math.max(1,Math.min(3,(height-72)/43));
for(int i=0;i<Math.min(count,list.size());i++) {
var entry=list.get(i).getAsJsonObject();
var profile=profiles.computeIfAbsent(str(entry,"authorId"),id -> net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(id)));
PlayerFaceExtractor.extractRenderState(g,profile,x+8,y+57+i*43,12);
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-34),x+25,y+59+i*43,0xFFBBBBBB,false);
}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) { }
if(closed)return;
map.tick(); updateHeader();
if(++ticks%6!=0 || pending() || !available())return;
if(ticks==6 || ticks%240==0){overview(screen);if(ticks>6){articles.refresh();notices.refresh();}}
else if(!articles.load())notices.load();
}
public PauseAtlasWidget map() { return map; }
@Override public void close() {closed=true;articles.close();notices.close();map.close();}
}
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.awt.image.BufferedImage;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
/** A locally owned texture; never installed under a user-controlled resource identifier. */
final class CommunityPhotoWidget extends AbstractButton implements AutoCloseable {
private final Identifier texture=Identifier.fromNamespaceAndPath("sanctuary","community/"+UUID.randomUUID());
private final int imageWidth,imageHeight;
private final Runnable action;
private final boolean caption;
private boolean closed;
CommunityPhotoWidget(int width,int height,BufferedImage image,Component label,boolean caption,Runnable action) {
super(0,0,width,height,label);this.action=action;this.caption=caption;
imageWidth=image.getWidth();imageHeight=image.getHeight();
var nativeImage=new NativeImage(imageWidth,imageHeight,false);
for(int y=0;y<imageHeight;y++)for(int x=0;x<imageWidth;x++)nativeImage.setPixel(x,y,0xff000000|image.getRGB(x,y));
Minecraft.getInstance().getTextureManager().register(texture,new DynamicTexture(()->"Gazette photo",nativeImage));
active=action!=null;
}
@Override public void onPress(InputWithModifiers input) {if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(closed)return;
int x=getX(),y=getY(),available=height-(caption?18:0);
g.fill(x,y,x+width,y+height,isHoveredOrFocused()?0xff536957:0xff1b2028);
double scale=Math.min((double)(width-4)/imageWidth,(double)(available-4)/imageHeight);
int w=(int)(imageWidth*scale),h=(int)(imageHeight*scale);
g.blit(RenderPipelines.GUI_TEXTURED,texture,x+(width-w)/2,y+(available-h)/2,0,0,w,h,imageWidth,imageHeight,imageWidth,imageHeight);
if(caption)g.text(Minecraft.getInstance().font,Minecraft.getInstance().font.plainSubstrByWidth(getMessage().getString(),width-10),x+5,y+height-13,0xffeeeeee,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
@Override public void close() {if(!closed){closed=true;Minecraft.getInstance().getTextureManager().release(texture);}}
}
@@ -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,6 +18,14 @@ 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 boolean publishing;
private int photoPart, sendAfter;
private String outgoingPhoto="";
private Runnable commit;
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.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;
moderator = flag(overview,"moderator");
@@ -25,7 +33,7 @@ public final class CommunityScreen extends BlocodexFrame {
}
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)); contentWidth=Math.min(520,contentWidth); 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"));
@@ -59,6 +67,7 @@ public final class CommunityScreen extends BlocodexFrame {
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));
photo(layout,w,str(entry,"photo"));
message(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")) {
@@ -99,7 +108,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"); }
rebuildWidgets();
});
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
@@ -119,21 +128,39 @@ public final class CommunityScreen extends BlocodexFrame {
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"));
}
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")) {outgoingPhoto=draft.photo;photoPart=0;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(w,Math.max(24,Math.min(200,w*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 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,11 +178,21 @@ 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 (!pending() && !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())); }
@Override public void tick() {
super.tick(); ticks++;
if(publishing && !pending() && ticks>=sendAfter) {
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()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
if (result.has("error")) message=str(result,"error");
else { data=result; moderator=flag(result,"moderator"); }
@@ -0,0 +1,108 @@
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 or 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"));
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,contentBottom-20);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>=450?3:2,cell=(w-(columns-1)*8)/columns;
for(int i=0;i<shots.size();i+=columns) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
Shot shot=shots.get(j);
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
}
}
}
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void preview() {
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
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(contentX+8,contentTop);addRenderableWidget(widget);
} catch(Exception e) {error="photo_invalid";selected=null;return;}
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(contentX+8,contentBottom-24,(w-6)/2,20).build());
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(contentX+8+(w+6)/2,contentBottom-24,(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,6 @@
package fr.koka.sanctuary.client;
/** Routes network responses to the current pause screen, never a static cross-server cache. */
public interface PauseAtlasAccess {
PauseAtlasWidget sanctuary$pauseMap();
}
@@ -0,0 +1,102 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.atlas.AtlasPayloads;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.material.MapColor;
/** Personal surveyed terrain only. A token and disposable texture belong to this pause instance. */
public final class PauseAtlasWidget extends AbstractButton implements AutoCloseable {
private static final int SIZE=AtlasPayloads.PIXELS;
private static final Identifier TEXTURE=SanctuaryMod.id("pause_atlas_viewport");
private final Screen owner;
private final Minecraft client=Minecraft.getInstance();
private DynamicTexture texture;
private int token,originX,originZ,refresh;
private double centerX,centerZ;
private String dimension="",status="loading";
private boolean dirty,readable;
private int pixels;
PauseAtlasWidget(Screen owner,int x,int y,int width,int height) {
super(x,y,width,height,Component.translatable("sanctuary.pause.map"));this.owner=owner;
setTooltip(Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
texture=new DynamicTexture("Sanctuary pause map",SIZE,SIZE,false);
client.getTextureManager().register(TEXTURE,texture);reset();request();
}
private void reset() {texture.getPixels().fillRect(0,0,SIZE,SIZE,0);dirty=true;readable=false;pixels=0;status="loading";}
private void request() {
if(client.player==null || client.level==null)return;
centerX=client.player.getX();centerZ=client.player.getZ();
int cx=Math.floorDiv((int)Math.floor(centerX),16),cz=Math.floorDiv((int)Math.floor(centerZ),16);
int nx=(cx-AtlasPayloads.SIDE/2)*16,nz=(cz-AtlasPayloads.SIDE/2)*16;
String next=client.level.dimension().identifier().toString();
if(nx!=originX || nz!=originZ || !next.equals(dimension))reset();
originX=nx;originZ=nz;dimension=next;token=AtlasClient.nextToken();refresh=1600;
AtlasClient.send(new AtlasPayloads.Request(token,cx,cz,false));
}
public void accept(AtlasPayloads.Response response) {
if(texture==null || response.token()!=token || client.level==null || !dimension.equals(client.level.dimension().identifier().toString()))return;
if(response.status().equals("retry")){refresh=12;return;}
status=response.status();readable=status.equals("ready") || status.equals("full");
if(!readable) {reset();status=response.status();refresh=100;return;}
for(var tile:response.tiles()) {
int x=tile.x()*16-originX,z=tile.z()*16-originZ;if(x<0 || z<0 || x+16>SIZE || z+16>SIZE)continue;
byte[] colors=tile.colors();
for(int dz=0;dz<16;dz++)for(int dx=0;dx<16;dx++) {
int packed=Byte.toUnsignedInt(colors[dz*16+dx]);
int color=packed<4?0:MapColor.getColorFromPackedId(packed);
int before=texture.getPixels().getPixel(x+dx,z+dz);
if(before==0 && color!=0)pixels++;else if(before!=0 && color==0)pixels--;
texture.getPixels().setPixel(x+dx,z+dz,color);
}
dirty=true;
}
if(response.done())refresh=100;
}
void tick() {
if(texture==null)return;
if(client.level!=null && !dimension.equals(client.level.dimension().identifier().toString())) {reset();request();}
if(--refresh<=0)request();
}
public int knownPixels() {return pixels;}
@Override public void onPress(InputWithModifiers input) {ProgressionClient.open(owner,"history");}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(texture==null)return;
if(dirty){texture.upload();dirty=false;}
int x=getX(),y=getY();var font=client.font;
g.fill(x,y,getRight(),getBottom(),0x90151B19);
g.enableScissor(x,y,getRight(),getBottom());
if(readable) {
double zoom=Math.max(.75,Math.max(width,height)/992.);
int left=x+width/2+(int)((originX-centerX)*zoom),top=y+height/2+(int)((originZ-centerZ)*zoom);
g.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,left,top,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
int px=x+width/2,pz=y+height/2;
if(client.player!=null) {
px+=(int)((client.player.getX()-centerX)*zoom);pz+=(int)((client.player.getZ()-centerZ)*zoom);
double yaw=Math.toRadians(client.player.getYRot());
for(int i=2;i<9;i++){int ax=px-(int)Math.round(Math.sin(yaw)*i),az=pz+(int)Math.round(Math.cos(yaw)*i);g.fill(ax-1,az-1,ax+2,az+2,0xFF141818);g.fill(ax,az,ax+1,az+1,0xFFFFFFFF);}
}
int color=ProgressionClient.identity==null?0xFFFFFFFF:0xFF000000|ProgressionClient.identity.color();
g.fill(px-3,pz-3,px+4,pz+4,0xFF141818);g.fill(px-2,pz-2,px+3,pz+3,color);
}
g.text(font,"N ↑",x+7,y+7,0xFFFFFFFF);
if(pixels==0)g.textWithWordWrap(font,SanctuaryAtlasScreen.label(readable?"empty":status),x+8,y+height/2+12,width-16,0xFFCCCCCC);
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-14),x+7,getBottom()-16,0xFFF0EEE6);
if(isHoveredOrFocused()) {g.fill(x,y,getRight(),y+1,0xFFD0CCAF);g.fill(x,getBottom()-1,getRight(),getBottom(),0xFFD0CCAF);}
g.disableScissor();
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
@Override public void close() {
if(texture!=null){AtlasClient.send(new AtlasPayloads.Request(0,0,0,false));client.getTextureManager().release(TEXTURE);texture=null;}
}
}
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.client;
import com.google.gson.*;
import java.util.*;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Each column owns its scrolling and reads the existing paginated contract. */
final class PauseCommunityFeed {
private final Screen owner;
private final String kind;
private final int width;
private final LinearLayout rows=LinearLayout.vertical().spacing(5);
private final ScrollableLayout scroll;
private final List<JsonObject> entries=new ArrayList<>();
private boolean needsLoad=true,more,closed,loaded;
private int page;
private String error="";
PauseCommunityFeed(Screen owner,Consumer<AbstractWidget> add,int x,int y,int w,int h,String kind) {
this.owner=owner;this.kind=kind;width=w-10;
var heading=new PauseTextButton(x,y,w-24,20,text(kind),()->open(owner,kind,"",false));
heading.setTooltip(Tooltip.create(text("browse")));add.accept(heading);
add.accept(Button.builder(Component.literal("+"),b->open(owner,kind,"",true)).bounds(x+w-20,y,20,20).tooltip(Tooltip.create(text("publish"))).build());
rows.addChild(new StringWidget(width,20,text(available()?"loading":"unavailable"),Minecraft.getInstance().font));
scroll=new ScrollableLayout(Minecraft.getInstance(),rows,Math.max(24,h-24),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setMinWidth(width);scroll.setMinHeight(Math.max(24,h-24));scroll.setPosition(x,y+24);scroll.arrangeElements();scroll.visitWidgets(add);
}
boolean load() {
if(closed || !needsLoad)return false;
if(!request(owner,"list","","",kind,"info","","",0,page,result->{
if(closed)return;
if(result.has("error")) {showError(str(result,"error"));return;}
var listing=result.getAsJsonObject("page");
var next=page==0?new ArrayList<JsonObject>():new ArrayList<>(entries);
for(var value:listing.getAsJsonArray("entries")) {
var entry=value.getAsJsonObject();
if(next.stream().noneMatch(e->str(e,"id").equals(str(entry,"id"))))next.add(entry);
}
boolean nextMore=flag(listing,"more");
if(loaded && error.isEmpty() && entries.equals(next) && more==nextMore)return;
loaded=true;error="";entries.clear();entries.addAll(next);more=nextMore;rebuild();
}))return false;
needsLoad=false;return true;
}
private void rebuild() {
rows.removeChildren();
var font=Minecraft.getInstance().font;
if(entries.isEmpty() && error.isEmpty())rows.addChild(new MultiLineTextWidget(text("empty"),font).setMaxWidth(width));
for(var entry:entries) {
Runnable action=()->open(owner,kind,str(entry,"id"),false);
if(kind.equals("article")) {
var title=Component.literal((flag(entry,"pinned")?"★ ":"")+str(entry,"title"));
var row=rows.addChild(LinearLayout.horizontal().spacing(5));
var face=row.addChild(new PlayerFaceWidget(20,net.minecraft.world.item.component.ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId")))));
face.setTooltip(Tooltip.create(Component.literal(str(entry,"authorName"))));
row.addChild(Button.builder(title,b->action.run()).width(width-25).tooltip(Tooltip.create(title.copy().append("\n").append(str(entry,"authorName")))).build());
} else rows.addChild(new NoticeCard(width,entry,action));
}
if(more && error.isEmpty())rows.addChild(Button.builder(text("more"),b->{page++;needsLoad=true;b.active=false;}).width(width).build());
scroll.arrangeElements();
}
private void showError(String error) {
this.error=error;rebuild();rows.addChild(new MultiLineTextWidget(text(error),Minecraft.getInstance().font).setMaxWidth(width));
rows.addChild(Button.builder(text("refresh"),b->{needsLoad=true;b.active=false;}).width(width).build());scroll.arrangeElements();
}
void refresh() {if(page==0 && scroll.getScrollAmount()==0 && error.isEmpty())needsLoad=true;}
void close() {closed=true;}
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);}
}
}
@@ -0,0 +1,26 @@
package fr.koka.sanctuary.client;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.network.chat.Component;
/** Quiet navigation: only the active target gains a background. */
final class PauseTextButton extends AbstractButton {
private final Runnable action;
PauseTextButton(int x,int y,int width,int height,Component label,Runnable action) {super(x,y,width,height,label);this.action=action;}
@Override public void onPress(InputWithModifiers input) {action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(isHoveredOrFocused())g.fill(getX(),getY(),getRight(),getBottom(),0x80424A48);
var font=Minecraft.getInstance().font;
if(height>24) {
var lines=font.split(getMessage(),width-10);int count=Math.min(2,lines.size());
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+(width-font.width(lines.get(i)))/2,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
return;
}
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-10),getX()+5,getY()+(height-9)/2,active?0xFFF0EEE6:0xFF969696);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
}
@@ -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 = 2, 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,10 @@ 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) {
public Entry {
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 +27,11 @@ 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) {
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);
}
}
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 +65,22 @@ 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(),""))) 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()));
tx.save(entry, true); return entry;
}
Entry old = visible(tx.get(change.id()));
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();
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()); }
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
case "close", "reopen" -> {
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
@@ -83,9 +90,14 @@ public final class Community {
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()));
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo);
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,7 +33,7 @@ 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() { }
@@ -43,7 +43,7 @@ public final class CommunityNetwork {
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()); }
});
}
private static void send(ServerPlayer player, Request request, JsonObject data) {
@@ -54,7 +54,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").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;
@@ -76,9 +76,22 @@ public final class CommunityNetwork {
}
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 "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)))));
}
if(request.action().equals("list") || request.action().equals("overview"))omitPhotos(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 +102,27 @@ public final class CommunityNetwork {
});
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
}
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());
}
CommunityConfig config;
CommunityStore store;
Runtime(MinecraftServer server) { this.server = server; }
@@ -0,0 +1,82 @@
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 encode(BufferedImage source,int maxWidth,int maxHeight) 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()<=MAX_BYTES)return Base64.getEncoder().encodeToString(bytes.toByteArray());
}
if(maxWidth>240)return encode(image,maxWidth/2,maxHeight/2);
throw new IOException("Image size");
}
}
@@ -15,6 +15,7 @@ public final class FileCommunityStore implements CommunityStore {
private final String serverId;
private final Clock clock;
private byte[] previous;
private boolean legacy;
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 {
@@ -23,7 +24,8 @@ public final class FileCommunityStore implements CommunityStore {
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()==1;
if ((!legacy && root.get("version").getAsInt() != VERSION) || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
for (var value : root.getAsJsonArray("entries")) {
Entry entry = JSON.fromJson(value, Entry.class);
if (entries.put(entry.id(), entry) != null) fail("invalid");
@@ -59,8 +61,14 @@ public final class FileCommunityStore implements CommunityStore {
if (dirty[0]) {
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
root.add("entries", JSON.toJsonTree(candidate.values()));
if (legacy) {
Path backup=path.resolveSibling(path.getFileName()+".v1.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;
entries = candidate; legacy=false;
}
return result;
}
@@ -35,7 +35,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"));
}
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);
@@ -72,11 +72,11 @@ public final class SqlCommunityStore implements CommunityStore {
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
public void save(Entry e, boolean insert) throws Exception {
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) 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())) { 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 photo=?,title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
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");
}
}
@@ -1,6 +1,9 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.CommunityClient;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.client.PauseAtlasAccess;
import fr.koka.sanctuary.client.PauseAtlasWidget;
import net.minecraft.client.gui.components.Button;
import fr.koka.sanctuary.client.CommunityPausePanels;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.PauseScreen;
@@ -12,15 +15,22 @@ import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(PauseScreen.class)
public abstract class CommunityPauseMixin extends Screen {
public abstract class CommunityPauseMixin extends Screen implements PauseAtlasAccess {
@Unique private CommunityPausePanels sanctuary$community;
protected CommunityPauseMixin(Component title) { super(title); }
@Inject(method="init",at=@At("TAIL"))
private void sanctuary$communityInit(CallbackInfo callback) {
if(sanctuary$community!=null)sanctuary$community.close();
sanctuary$community=null;
if (((PauseScreen)(Object)this).showsPauseMenu() && CommunityClient.available())
sanctuary$community=new CommunityPausePanels(this,this::addRenderableWidget);
if (((PauseScreen)(Object)this).showsPauseMenu() && ProgressionClient.active()) {
var nativeButtons=children().stream().filter(Button.class::isInstance).map(Button.class::cast).toList();
clearWidgets();
sanctuary$community=new CommunityPausePanels(this,this::addRenderableWidget,nativeButtons);
}
}
@Inject(method="tick",at=@At("TAIL"))
private void sanctuary$communityTick(CallbackInfo callback) { if(sanctuary$community!=null)sanctuary$community.tick(); }
@Override public PauseAtlasWidget sanctuary$pauseMap() {return sanctuary$community==null?null:sanctuary$community.map();}
@Inject(method="removed",at=@At("HEAD"))
private void sanctuary$communityRemoved(CallbackInfo callback) {if(sanctuary$community!=null)sanctuary$community.close();}
}
@@ -0,0 +1,15 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.atlas.AtlasService;
import net.minecraft.client.server.IntegratedServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Reading an existing survey must work without unpausing a single-player world. */
@Mixin(IntegratedServer.class)
public abstract class PauseAtlasServerMixin {
@Inject(method="tickPaused",at=@At("TAIL"))
private void sanctuary$sendPausedAtlas(CallbackInfo callback) {AtlasService.streamPaused((IntegratedServer)(Object)this);}
}
@@ -1,53 +0,0 @@
package fr.koka.sanctuary.mixin.client;
import com.llamalad7.mixinextras.sugar.Local;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.client.ArenaClient;
import fr.koka.sanctuary.client.CycleClient;
import fr.koka.sanctuary.cycle.CycleService;
import fr.koka.sanctuary.arena.Arenas;
import fr.koka.sanctuary.progression.ProgressionService;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.layouts.GridLayout;
import net.minecraft.client.gui.layouts.LayoutElement;
import net.minecraft.client.gui.layouts.LayoutSettings;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.contents.TranslatableContents;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(PauseScreen.class)
public abstract class ProgressionPauseMixin {
@Inject(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/LinearLayout;horizontal()Lnet/minecraft/client/gui/layouts/LinearLayout;"))
private void sanctuary$blocodex(CallbackInfo callback,@Local GridLayout.RowHelper helper) {
if (!ProgressionClient.active()) return;
Screen parent=(Screen)(Object)this;
for (String target:new String[]{"blocks","progress","history","identity"})
helper.addChild(Button.builder(ProgressionService.label(target),
b -> ProgressionClient.open(parent,target)).width(98).build());
helper.addChild(Button.builder(CycleService.label("faction"),
b -> CycleClient.open(parent,"faction")).width(98).build());
helper.addChild(Button.builder(Arenas.text("title"),
b -> ArenaClient.open(parent)).width(98).build());
}
// These native destinations now live inside Discovery and Progression.
@WrapOperation(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/GridLayout$RowHelper;addChild(Lnet/minecraft/client/gui/layouts/LayoutElement;)Lnet/minecraft/client/gui/layouts/LayoutElement;"))
private LayoutElement sanctuary$groupNativePages(GridLayout.RowHelper helper,LayoutElement child,Operation<LayoutElement> original) {
if (ProgressionClient.active() && child instanceof Button button
&& button.getMessage().getContents() instanceof TranslatableContents text
&& (text.getKey().equals("gui.advancements") || text.getKey().equals("gui.stats"))) return child;
return original.call(helper,child);
}
// Keep native actions and server additions; omit the feedback/social icon row entirely.
@WrapOperation(method="createPauseMenu",at=@At(value="INVOKE",target="Lnet/minecraft/client/gui/layouts/GridLayout$RowHelper;addChild(Lnet/minecraft/client/gui/layouts/LayoutElement;ILnet/minecraft/client/gui/layouts/LayoutSettings;)Lnet/minecraft/client/gui/layouts/LayoutElement;"))
private LayoutElement sanctuary$omitIcons(GridLayout.RowHelper helper,LayoutElement child,int columns,LayoutSettings settings,Operation<LayoutElement> original) {
if (ProgressionClient.active() && child instanceof LinearLayout) return child;
return original.call(helper,child,columns,settings);
}
}
@@ -2390,5 +2390,20 @@
"sanctuary.community.already_linked": "This player or account is already linked.",
"sanctuary.community.actions": "⋯",
"sanctuary.community.actions_for": "Actions · %s",
"sanctuary.community.reply_hint": "Write a reply…"
"sanctuary.community.reply_hint": "Write a reply…",
"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.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."
}
@@ -2390,5 +2390,20 @@
"sanctuary.community.already_linked": "Ce joueur ou ce compte est déjà lié.",
"sanctuary.community.actions": "⋯",
"sanctuary.community.actions_for": "Actions · %s",
"sanctuary.community.reply_hint": "Écrire une réponse…"
"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.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."
}
@@ -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;
@@ -204,7 +204,6 @@
"client.MobNameRendererMixin",
"client.LivingMobNameRendererMixin",
"client.ProgressionHudMixin",
"client.ProgressionPauseMixin",
"client.DiscoveryStatsMixin",
"client.NotificationOptionsMixin",
"client.InventoryHotbarVisualMixin",
@@ -289,6 +288,7 @@
"client.NaturalWaterFogMixin",
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess",
"client.PauseAtlasServerMixin",
"client.CommunityPauseMixin"
],
"injectors": {
@@ -17,7 +17,7 @@ public final class CommunityInterop {
fixture=new JsonObject();
for(String key:new String[]{"server","player","article"})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));
Files.writeString(file,json.toJson(fixture));
} else {
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
@@ -32,6 +32,8 @@ public final class CommunityInterop {
} else if(phase.equals("verify")) {
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,7 +8,14 @@ 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; }
@@ -37,9 +44,19 @@ 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-");
@@ -72,6 +89,26 @@ 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));
Path legacy=dir.resolve("legacy.json");
var root=new com.google.gson.JsonObject();root.addProperty("version",1);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.json.v1.bak")).equals(before),"Exact migration backup");
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
System.out.println("COMMUNITY157_PHOTOS_PASS");
System.out.println("COMMUNITY154_FILE_PASS");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.154"
version = "beta.157"
pack-format = "packwiz:1.1.0"
[index]