Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9791d178ab | ||
|
|
32158e2e05 |
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# beta.157 — photo obligatoire et galerie de captures
|
||||
|
||||
## Contrat de migration communautaire v2
|
||||
|
||||
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
|
||||
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
|
||||
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
|
||||
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
|
||||
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
|
||||
|
||||
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
|
||||
leur prochaine modification exige une photo. Avis, intendance et réponses restent
|
||||
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
|
||||
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
|
||||
référence à un chemin local, d'URL distante ou de média orphelin persistant.
|
||||
|
||||
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
|
||||
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
|
||||
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
|
||||
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
|
||||
de restaurer la copie v1 et perd les changements communautaires postérieurs.
|
||||
Ne pas restaurer pendant que le serveur tourne.
|
||||
|
||||
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
|
||||
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
|
||||
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
|
||||
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
|
||||
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
|
||||
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
|
||||
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
|
||||
par la préparation de cette livraison.
|
||||
|
||||
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
|
||||
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
|
||||
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
|
||||
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
|
||||
seul le détail d'un article les contient. Le plafond des réponses
|
||||
passe à 262 144 caractères pour une photo et une discussion complète ; les
|
||||
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
|
||||
anciens clients ne peuvent plus créer d'article sans photo.
|
||||
|
||||
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
|
||||
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
|
||||
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
|
||||
Les captures illisibles restent visibles mais désactivées. Limites sources :
|
||||
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
|
||||
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
|
||||
du dossier `screenshots` de son instance.
|
||||
|
||||
## Vérifications
|
||||
|
||||
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
|
||||
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
|
||||
désactivé sans photo, envoi fragmenté, photo visible et relecture après
|
||||
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
|
||||
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
|
||||
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
|
||||
copie de migration exacte, fragments hors ordre/incomplets/expirés.
|
||||
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
|
||||
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
|
||||
anciennes publications et conservation après modification.
|
||||
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
|
||||
modification et réponse Web relues en Java, article/photo du site décodés par
|
||||
Java. Migration Laravel et alternative SQL testées sur bases de développement.
|
||||
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
|
||||
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
|
||||
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
|
||||
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
|
||||
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
|
||||
indépendants, pagination, article et avis longs, réponse, options et reprise :
|
||||
réussie avec les articles munis d'une photo.
|
||||
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
|
||||
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
|
||||
client natif du menu pause), 2 min 56 s.
|
||||
|
||||
Les captures d'essai sont des mires générées dans une instance de développement,
|
||||
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
|
||||
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
|
||||
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
|
||||
|
||||
|
||||
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
|
||||
`9626e95`, sans push ni publication. Logs de développement :
|
||||
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
|
||||
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
|
||||
`build/photos157-final-build.log`.
|
||||
|
||||
## Artefacts locaux
|
||||
|
||||
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
|
||||
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
|
||||
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
|
||||
Reçu complet : `build/photos157-validation.json`.
|
||||
|
||||
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
|
||||
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
|
||||
@@ -0,0 +1,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
@@ -9,8 +9,8 @@ loom_version=1.17.20
|
||||
fabric_api_version=0.160.5+26.3
|
||||
|
||||
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
|
||||
mod_version=beta.155
|
||||
pack_version=beta.155
|
||||
mod_version=beta.157
|
||||
pack_version=beta.157
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -258,6 +258,9 @@ 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') }
|
||||
|
||||
+2
-1
@@ -69,7 +69,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
|
||||
c.waitTicks(25);
|
||||
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
|
||||
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
|
||||
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.DRAFTS.computeIfAbsent("new:article",unused->new CommunityClient.Draft()).photo=testPhoto(); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
|
||||
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
|
||||
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
|
||||
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
|
||||
@@ -80,6 +80,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
|
||||
c.runOnClient(m -> m.gui.setScreen(null));
|
||||
} finally { intro.set(false); }
|
||||
}
|
||||
static String testPhoto() {try {return fr.koka.sanctuary.community.CommunityPhoto.encode(new java.awt.image.BufferedImage(16,9,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}catch(Exception e){throw new AssertionError(e);}}
|
||||
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
|
||||
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
|
||||
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
|
||||
|
||||
+4
-2
@@ -16,6 +16,7 @@ import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
|
||||
public final class Pause155ClientChecks implements FabricClientGameTest {
|
||||
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
var intro=new AtomicBoolean(true);
|
||||
@@ -41,7 +42,7 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
|
||||
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
|
||||
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
|
||||
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
|
||||
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0));
|
||||
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
|
||||
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
|
||||
} catch(Exception e) {throw new AssertionError(e);}
|
||||
});
|
||||
@@ -59,7 +60,8 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
|
||||
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
|
||||
var options=nativeButton(s,"menu.options");var worldOptions=nativeButton(s,"options.worldOptions.button");var quit=nativeButton(s,"menu.returnToMenu");var resume=nativeButton(s,"menu.returnToGame");
|
||||
check(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-8,"Options and quit stacked at bottom left");
|
||||
check(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getBottom()==s.height-8,"Resume below central map");
|
||||
var feed=areas(s).getFirst();
|
||||
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
|
||||
for(var e:s.children())if(e instanceof Button b && b.getX()==8 && b.getY()<options.getY())check(b.getBottom()<options.getY(),"Navigation does not overlap options");
|
||||
check(!has(s,"gui.advancements") && !has(s,"gui.stats") && !has(s,"menu.game"),"Old grid removed");
|
||||
for(var e:s.children())if(e instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Widget bounds "+w.getClass().getSimpleName()+" "+w.getX()+","+w.getY()+" "+w.getWidth()+"x"+w.getHeight()+" / "+s.width+"x"+s.height);
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import fr.koka.sanctuary.community.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||
import net.minecraft.client.gui.components.*;
|
||||
import net.minecraft.client.gui.screens.*;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
|
||||
|
||||
/** Screenshot selection and article round trip through actual client/server packets. */
|
||||
public final class Photos157ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
|
||||
var intro=new AtomicBoolean(true);
|
||||
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
|
||||
try {
|
||||
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
|
||||
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
|
||||
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
|
||||
} catch(Exception e){throw new AssertionError(e);}
|
||||
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
|
||||
try {
|
||||
Files.createDirectories(folder.get());
|
||||
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
|
||||
var random=new Random(157);
|
||||
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
|
||||
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
|
||||
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
|
||||
}catch(Exception e){throw new AssertionError(e);}
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
|
||||
Starter039TestSupport.selectFirst(screen);
|
||||
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
|
||||
press(screen,"sanctuary.progression.enter");intro.set(false);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
|
||||
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
|
||||
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
|
||||
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
|
||||
c.runOnClient(m->{
|
||||
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
CommunityClient.open(new PauseScreen(true),"article","",true);
|
||||
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
|
||||
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
|
||||
press(m.gui.screen(),"sanctuary.community.photo_choose");
|
||||
});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->{
|
||||
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
|
||||
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
|
||||
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
|
||||
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
|
||||
});
|
||||
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
|
||||
c.runOnClient(m->m.gui.screen().onClose());
|
||||
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
|
||||
}
|
||||
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
|
||||
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
|
||||
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
|
||||
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
|
||||
c.takeScreenshot("photos157-preview");
|
||||
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
|
||||
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
|
||||
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
|
||||
c.runOnClient(m->check(widgets(m.gui.screen()).anyMatch(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 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); }
|
||||
|
||||
@@ -47,7 +47,7 @@ public final class CommunityPausePanels implements AutoCloseable {
|
||||
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+4*step,left,navHeight).build());
|
||||
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+5*step,left,navHeight).build());
|
||||
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
|
||||
place(nativeButtons,add,"menu.returnToGame",(screen.width-Math.min(180,centerWidth))/2,screen.height-28,Math.min(180,centerWidth));
|
||||
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
|
||||
int feedHeight=(bottom-top-gap)/2;
|
||||
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
|
||||
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
+108
@@ -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;}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2394,5 +2394,16 @@
|
||||
"sanctuary.pause.map": "World map",
|
||||
"sanctuary.pause.map_hint": "Open the map and explore",
|
||||
"sanctuary.community.more": "Load more",
|
||||
"sanctuary.community.browse": "All publications"
|
||||
"sanctuary.community.browse": "All publications",
|
||||
"sanctuary.community.photo": "Article photo",
|
||||
"sanctuary.community.photo_choose": "Choose a screenshot…",
|
||||
"sanctuary.community.photo_change": "Change photo…",
|
||||
"sanctuary.community.photo_assign": "Assign to article",
|
||||
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
|
||||
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
|
||||
"sanctuary.community.photo_uploading": "Uploading photo…",
|
||||
"sanctuary.community.screenshots": "Screenshots",
|
||||
"sanctuary.community.screenshots_back": "Back to screenshots",
|
||||
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
|
||||
"sanctuary.community.screenshots_error": "Could not read screenshots."
|
||||
}
|
||||
|
||||
@@ -2394,5 +2394,16 @@
|
||||
"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.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;
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.155"
|
||||
version = "beta.157"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user