Compare commits

...
1 Commits
Author SHA1 Message Date
koka bdaad0c940 Add community search, responsive gallery and followed notice map markers
Build Sanctuary / build (push) Waiting to run
2026-09-23 01:20:17 +02:00
23 changed files with 449 additions and 56 deletions
+7
View File
@@ -1,5 +1,12 @@
# Sanctuary
## beta.159 — recherche communautaire et galerie adaptable (pack local)
Recherche par joueur, titre ou texte, historique Serveur en lecture seule,
année dans les dates et galerie adaptée au GUI avec grand aperçu.
Repères « ! » et infobulles sur la carte pour les demandes suivies géolocalisées.
[Résultat et vérifications](docs/community-search-beta159.md).
## beta.158 — cartes communautaires et demandes suivies (pack local)
Grilles à deux colonnes, articles photo/texte, annonces colorées avec visages,
+39
View File
@@ -199,3 +199,42 @@ Les anciens boutons de liaison et d'historique public de l'intendance disparaiss
En mode fichier, le panneau d’intendance en jeu s’ouvre avec
`/sanctuary community admin` ; le serveur exige les permissions opérateur.
## Évolution beta.159 — recherche et lecture de l’intendance
Le schéma reste **v3** : aucune migration de données. Cette évolution remplace
la restriction de lecture publique de beta.158 : les onglets Gazette, Tableau
et Serveur permettent de lire leurs historiques. La publication et la gestion
de l’intendance restent dans l’administration, sans réponse ni abonnement.
`list` et `cards` acceptent une recherche facultative dans `Request.body` (120
caractères maximum, vide par défaut). Le site utilise le paramètre GET `q`,
conservé dans la pagination. Recherche littérale de sous-chaîne dans le nom de
l’auteur, le titre **ou** le corps, sans distinction de casse, avant pagination.
Les accents restent significatifs et `%` / `_` sont des caractères ordinaires.
Le filtre reste limité au serveur et à la rubrique sélectionnés ; les contenus
masqués restent exclus. Les épingles gardent leur priorité. Le client attend
300 ms après la frappe et ignore les réponses correspondant à un ancien filtre.
Les pages restent bornées à 10 publications ; aucune recherche dans les réponses.
Les dates des publications affichent l’année (UTC). Le menu pause affiche la date
complète dans la langue et le fuseau local du joueur. La galerie utilise la
surface disponible du GUI, adapte ses colonnes et conserve ses pages de 24
captures défilantes. Le clic ouvre un aperçu dans cette surface, en conservant
les proportions et les boutons Retour / Assigner. Le formulaire conserve son
aperçu compact. Les PNG sources ne sont pas modifiés.
Les demandes suivies ouvertes qui ont une position affichent un `!` sur la carte
de pause et le grand atlas, dans la dimension correspondante. Elles ne révèlent
pas le terrain et ne chargent aucun chunk. L’infobulle montre titre, auteur,
résumé, coordonnées, matériaux et récompense ; les listes longues sont abrégées
pour rester dans le GUI. Le clic ouvre l’annonce. « Voir sur la carte » centre
l’atlas sur son lieu dans la dimension courante, sans téléportation. Les repères
suivent tous les abonnements, indépendamment des trois suivis visibles dans le
HUD ; ils disparaissent au désabonnement, à la clôture ou au masquage. Un
rafraîchissement des suivis reste actif lorsque la carte ou la pause est ouverte
(période de cinq secondes, différée si une autre requête est en cours).
Le message réseau `following` ajoute `summary`, extrait littéral du corps limité
à 240 caractères Unicode plus une ellipse. Le texte intégral et les photos ne
sont pas diffusés dans ce message. Aucun champ persistant supplémentaire.
+81
View File
@@ -0,0 +1,81 @@
# beta.159 — recherche communautaire et galerie adaptable
Branche `codex/community-search-gallery-beta159`, socle beta.158.
## Résultat
Une barre de recherche filtre les publications par auteur, titre ou texte côté
serveur, avant pagination. Le filtre accompagne les pages suivantes, conserve
les épingles et se réinitialise en effaçant le champ. Les réponses aux anciennes
recherches sont ignorées et le focus de saisie est conservé.
Les trois onglets Gazette / Tableau / Serveur donnent accès aux publications de
chaque rubrique. L’en-tête d’intendance de la pause ouvre aussi son historique.
La lecture est publique ; la publication et la gestion des messages du serveur
restent réservées au panneau administrateur, même pour un opérateur visitant
l’écran public. Les messages du serveur n’acceptent ni réponses ni abonnements.
L’année figure dans la date de pause et les métadonnées des publications. La
galerie utilise l’espace disponible aux différentes échelles de GUI et tailles
de fenêtre. Les captures restent paginées par 24 avec défilement. Cliquer une
capture ouvre un grand aperçu avec les commandes de retour et d’assignation.
La photo assignée reste compacte dans le formulaire et les brouillons sont
conservés lors du retour. Aucune modification des PNG originaux.
Les annonces suivies géolocalisées portent un « ! » sur les deux cartes.
L’infobulle fournit titre, auteur, résumé, coordonnées, matériaux et récompense
sans ouvrir la discussion ; le clic reste disponible. Un bouton dans l’annonce
centre le grand atlas sur sa position, dans la dimension courante. Aucune
téléportation ni révélation du terrain : seules les coordonnées publiées sont
projetées. Les annonces fermées, masquées, sans lieu ou non suivies ne portent
pas de repère. Les longues infobulles sont abrégées pour tenir dans le GUI.
Le site suit le même contrat de recherche et de lecture. Schéma v3 inchangé,
aucune migration de sauvegarde ni de base. Libellés français et anglais.
## Vérifications
- Stockage fichier et MariaDB : recherche par auteur et corps, casse et accents,
filtre avant pagination, épingles conservées, caractères `%` / `_` littéraux,
rubrique et serveur isolés, contenus masqués exclus, borne de 120 caractères.
- Client natif Minecraft 26.3 / Java 25 / Vulkan : galerie et aperçu en FR/EN,
GUI 2/3/4 et fenêtres 1280×960 / 1920×1080 ; retour au brouillon, assignation,
publication, listes défilantes et chargement des pages suivantes.
- Recherche en jeu au-delà de la première page, filtre par joueur, effacement,
conservation du focus et rejet d’une réponse correspondant à l’ancien filtre.
- Lecture des messages serveur par un joueur ordinaire, aucun contrôle d’écriture
dans les pages publiques, y compris pour l’opérateur ; administration inchangée.
- Carte : projection dans la dimension correspondante, titre/auteur/résumé,
matériaux et récompense au survol ; ouverture de la demande par clic sur les
deux cartes ; retrait immédiat du repère après désabonnement.
- Date avec année en FR/EN, GUI 2/3/4, sans chevauchement de l’intendance.
- Site : 24 tests / 157 assertions sur SQLite puis MariaDB de développement,
formatage Pint réussi. Commit local `e70e78d`, branche `codex/community-search`.
La commande complète `./gradlew :sanctuary:runClientGameTest check build assemblePack`
avec les propriétés client Cards158, QuickTests et Vulkan a exécuté 252 GameTests :
229 réussites et les mêmes 23 échecs que beta.158. Aucun test serveur supprimé
ou modifié. La comparaison est conservée dans `build/search159-server-failures.json`
et le journal dans `build/beta159-check-build.log`.
Les contrôles restants, la compilation et `assemblePack` passent avec la suite
serveur déjà exécutée exclue (`-x :sanctuary:runGameTest`) : 133 tâches, journal
`build/beta159-release.log`. Le parcours client final passe en mode MariaDB,
puis en mode fichier (`build/beta159-file-client.log`). Les repères ont été
vérifiés avec et sans terrain d’atlas débloqué ; les infobulles restent dans la
fenêtre aux GUI 2/3/4. Captures représentatives inspectées :
`build/search159-file-screens/` et `build/search159-database-screens/`.
L’export packwiz est vérifié : intégrité ZIP, version beta.159, JAR embarqué
identique au JAR de livraison. Les 29 ressources de shaders sont identiques à
beta.158, qui conserve le socle beta.151. Les anciens JAR et MRpack beta.154 à
beta.158 restent présents. Aucun monde personnel, déploiement de serveur, canal
packwiz ou instance Prism n’est modifié.
## Artefacts locaux
- `mods/sanctuary/build/libs/sanctuary-beta.159.jar` — SHA-256 `c30d57a5f37e0a5c9dbb387ca831ce9e5c15a1dfe4cb55af0af20a376e862576`.
- `build/Sanctuary-beta.159.mrpack` — SHA-256 `868932d88bc241bfb526a7c5e4054009e247658bd0784b7141d82017edcc2823`.
+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.158
pack_version=beta.158
mod_version=beta.159
pack_version=beta.159
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
@@ -43,17 +43,26 @@ public final class Community158ClientChecks 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(),"",config.serverId()):new FileCommunityStore(server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var author=new Community.Actor(UUID.randomUUID().toString(),"Voisin",true);
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Annonce officielle159","Le serveur vous souhaite la bienvenue.",0));
for(int i=0;i<13;i++) {
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","article","info","Gazette "+i,"Le village raconte. ".repeat(90),0,testPhoto()));
store.change(author,new Community.Change("create",i==0?noticeId:UUID.randomUUID().toString(),"","notice",List.of("info","event","work","need").get(i%4),"Demande "+i,"Rendez-vous au village. ".repeat(40),0,null,CommunityTask.EMPTY));
store.change(author,new Community.Change("create",UUID.randomUUID().toString(),"","article","info","Gazette "+i,"Le village raconte. ".repeat(90)+(i==0?" Unique159":""),0,testPhoto()));
store.change(author,new Community.Change("create",i==0?noticeId:UUID.randomUUID().toString(),"","notice",List.of("info","event","work","need").get(i%4),"Demande "+i,"Rendez-vous au village. ".repeat(40),0,null,i==0?new CommunityTask(new CommunityTask.Location("minecraft:overworld",0,64,0),List.of(new CommunityTask.Material("minecraft:oak_log",128)),"shared","custom",List.of(),"Un repas au village"):CommunityTask.EMPTY));
}
}catch(Exception error){throw new AssertionError(error);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Cards158 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
world.getServer().runOnServer(s->{
var player=s.getPlayerList().getPlayers().getFirst();player.getAbilities().invulnerable=true;player.setExperienceLevels(20);
check(fr.koka.sanctuary.progression.ProgressionService.purchase(player,"atlas",fr.koka.sanctuary.progression.ProgressEffects.get(player).revision()),"Atlas unlocked through progression");
fr.koka.sanctuary.progression.ProgressionService.send(player,"");
for(int z=-2;z<=2;z++)for(int x=-2;x<=2;x++) {
var tile=fr.koka.sanctuary.atlas.AtlasSampler.sample(player.level(),player.chunkPosition().x()+x,player.chunkPosition().z()+z);
if(tile!=null)fr.koka.sanctuary.atlas.AtlasService.observations(player).observe(tile);
}
});
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4))for(int windowWidth:List.of(1280,1920)) {
c.runOnClient(m->{
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.getWindow().setWindowed(windowWidth,windowWidth==1280?960:1080);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
@@ -63,23 +72,29 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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);
if(a.maxScrollAmount()>0){s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);}
var photos=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).toList();
check(photos.stream().mapToInt(AbstractWidget::getRight).max().orElseThrow()>s.width*0.8,"Gallery uses GUI width");
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("cards158-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
c.takeScreenshot("search159-gallery-"+locale+"-"+scale+"-"+windowWidth+(database?"-database":"-file"));
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.runOnClient(m->{var s=m.gui.screen();var image=widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow();check(image.getWidth()>s.width*0.8&&image.getHeight()>s.height*0.5,"Preview fills available GUI");check(button(s,"photo_assign").getBottom()<s.height-30,"Preview controls remain visible");});
c.takeScreenshot("search159-preview-"+locale+"-"+scale+"-"+windowWidth);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.screenshots_back"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.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.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&button(m.gui.screen(),"previous").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("cards158-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
c.runOnClient(m->{var article=widgets(m.gui.screen()).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();var face=widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).findFirst().orElseThrow();check(face.getY()>=article.getBottom(),"Author metadata below the article");check(button(m.gui.screen(),"actions").getY()==face.getY(),"Author metadata next to ellipsis");});
c.runOnClient(m->{var article=widgets(m.gui.screen()).filter(CommunityArticle.class::isInstance).map(CommunityArticle.class::cast).findFirst().orElseThrow();var face=widgets(m.gui.screen()).filter(PlayerFaceWidget.class::isInstance).map(PlayerFaceWidget.class::cast).findFirst().orElseThrow();check(face.getY()>=article.getBottom(),"Author metadata below the article");check(button(m.gui.screen(),"actions").getY()==face.getY(),"Author metadata next to ellipsis");check(widgets(m.gui.screen()).anyMatch(w->w instanceof AbstractWidget widget && widget.getMessage().getString().contains("/"+java.time.Year.now()+" ")),"Article date includes year");});
c.takeScreenshot("cards158-article"+(database?"-database":"-file"));
world.getServer().runOnServer(s->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
@@ -92,7 +107,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{var screen=m.gui.screen();var cards=widgets(screen).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).toList();
check(cards.get(0).getY()==cards.get(1).getY()&&cards.get(0).getRight()<cards.get(1).getX(),"Two-column grid");
check(cards.get(2).getY()>cards.get(0).getY(),"Rows continue vertically");
check(!has(screen,"sanctuary.community.link")&&!has(screen,"sanctuary.community.bulletin"),"Public navigation excludes linking and bulletin");
check(!has(screen,"sanctuary.community.link")&&has(screen,"sanctuary.community.server_tab"),"Public navigation restores read-only server history");
check(widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.maxScrollAmount()>0),"Grid scrolls");
});
c.takeScreenshot("cards158-grid-"+kind+"-"+locale+"-"+scale+(database?"-database":"-file"));
@@ -100,6 +115,24 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{var a=widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();a.setScrollAmount(a.maxScrollAmount());press(m.gui.screen(),"sanctuary.community.more");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->check(widgets(m.gui.screen()).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).anyMatch(a->a.scrollAmount()>0),"Appending cards retains scroll"));
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"article","",false);edit(m.gui.screen()).setValue("unique159");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 0"),"Search finds body beyond first page");edit(m.gui.screen()).setValue("VoISin");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.more"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==13&&!CommunityClient.pending(),300);
c.runOnClient(m->{edit(m.gui.screen()).setValue("absent159");m.gui.screen().setFocused(edit(m.gui.screen()));for(int tick=0;tick<12;tick++)m.gui.screen().tick();check(CommunityClient.pending(),"Old search is in flight");edit(m.gui.screen()).setValue("Gazette 12");});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(text(m.gui.screen(),"Gazette 12"),"Latest search wins over prior pending request");check(edit(m.gui.screen()).isFocused(),"Search retains keyboard focus");});
c.takeScreenshot("search159-filtered");
c.runOnClient(m->edit(m.gui.screen()).setValue(""));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==10&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.server_tab"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==1&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public server listing is read only");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),300);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Public bulletin has no write actions");check(text(m.gui.screen(),"Annonce officielle159"),"Server bulletin readable by ordinary player");});
c.takeScreenshot("search159-server-readonly");
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();inventory.setItem(6,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.OAK_LOG,17));inventory.setItem(7,new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIAMOND,3));});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Le coffre descriptif");body(m.gui.screen()).setValue("Du bois pour le pont");press(m.gui.screen(),"sanctuary.community.task_configure");press(m.gui.screen(),"sanctuary.community.location_current");press(m.gui.screen(),"sanctuary.community.materials");edit(m.gui.screen()).setValue("minecraft:oak_log");});
c.waitTicks(3);
@@ -115,6 +148,33 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(NotificationClient.tracked().stream().anyMatch(t->t.title().getString().equals("Demande 0")),"Server-backed quest appears in HUD"));
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(5);c.takeScreenshot("cards158-follow-hud");
c.runOnClient(m->{CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.map_show")&&!CommunityClient.pending(),300);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.map_show"));
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&!a.questPins().isEmpty(),200);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Followed location appears on atlas");check(CommunityMapMarkers.project("minecraft:the_nether",x->100,z->100,0,0,300,300).isEmpty(),"Other dimension has no pin");
var tooltip=new StringBuilder();for(var line:CommunityMapMarkers.tooltip(pin.marker())){line.accept((index,style,point)->{tooltip.appendCodePoint(point);return true;});tooltip.append(' ');}
check(tooltip.toString().contains("Voisin")&&tooltip.toString().contains("128")&&tooltip.toString().contains("Un repas")&&tooltip.toString().contains("Rendez-vous"),"Hover includes author, summary, materials and reward");
hover(m,pin.x(),pin.y());
});
c.waitTicks(3);c.takeScreenshot("search159-map-hover"+(database?"-database":"-file"));
for(int scale:List.of(3,4)) {
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();var pin=((SanctuaryAtlasScreen)m.gui.screen()).questPins().getFirst();hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-map-hover-gui"+scale);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();var pin=a.questPins().getFirst();check(a.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false),"Quest marker click handled");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);
world.getServer().runOnServer(server->server.getPlayerList().getPlayers().getFirst().teleportTo(20.5,65,20.5));
c.waitTicks(5);c.runOnClient(m->m.gui.setScreen(new PauseScreen(true)));c.waitTicks(20);
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();check(pin.marker().id().equals(noticeId),"Pause map includes followed location");hover(m,pin.x(),pin.y());});
c.waitTicks(3);c.takeScreenshot("search159-pause-map-hover"+(database?"-database":"-file"));
c.runOnClient(m->{var map=((PauseAtlasAccess)m.gui.screen()).sanctuary$pauseMap();var pin=map.questPins().getFirst();map.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(pin.x(),pin.y(),new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.unfollow")&&!CommunityClient.pending(),300);c.waitTicks(25);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.unfollow"));
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);
c.runOnClient(m->check(CommunityMapMarkers.project("minecraft:overworld",x->100,z->100,0,0,300,300).isEmpty(),"Unfollowing removes the map marker immediately"));
c.runOnClient(m->m.gui.setScreen(null));
c.runOnClient(m->m.getConnection().sendCommand("sanctuary community admin"));c.waitTicks(5);
c.runOnClient(m->check(m.gui.screen()==null,"Non-operator cannot open administration"));
@@ -124,9 +184,29 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.waitFor(m->m.gui.screen() instanceof CommunityScreen&&has(m.gui.screen(),"sanctuary.community.publish")&&!CommunityClient.pending(),200);
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.publish");edit(m.gui.screen()).setValue("Message officiel");body(m.gui.screen()).setValue("Bienvenue !");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");m.gui.setScreen(null);});
System.out.println("CARDS158_CLIENT_PASS mode="+(database?"database":"file"));
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");CommunityClient.open(null,"bulletin","",false);});
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).count()==2&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.publish"),"Public bulletin remains read only for operators");widgets(m.gui.screen()).filter(CommunityCard.class::isInstance).map(CommunityCard.class::cast).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.actions"),"Operator uses administration to edit official messages");m.gui.setScreen(new PauseScreen(true));});
c.waitFor(m->CommunityClient.overview.has("bulletins")&&!CommunityClient.pending(),200);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitTicks(20);
c.runOnClient(m->{
var date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().endsWith(java.time.Year.now().toString())).findFirst().orElseThrow();
var official=widgets(m.gui.screen()).filter(PauseTextButton.class::isInstance).map(PauseTextButton.class::cast).filter(w->w.getMessage().getString().contains("Message officiel")).findFirst().orElseThrow();
check(date.getRight()<official.getX(),"Full date and server message do not overlap");
});
c.takeScreenshot("search159-pause-year-"+locale+"-"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("SEARCH159_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
private static void hover(net.minecraft.client.Minecraft m,double x,double y) {
var w=m.getWindow();double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();
}
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
}
@@ -23,7 +23,7 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
CommunityCard(int width,JsonObject entry,boolean compact,Runnable action) {
super(0,0,width,compact?44:90,Component.literal(str(entry,"title")));this.entry=entry;this.compact=compact;this.action=action;article=str(entry,"kind").equals("article");
face=new PlayerFaceWidget(compact?20:24,ResolvableProfile.createUnresolved(UUID.fromString(str(entry,"authorId"))));
setTooltip(Tooltip.create(getMessage().copy().append("\n").append(str(entry,"authorName"))));
setTooltip(Tooltip.create(getMessage().copy().append("\n").append(str(entry,"authorName")).append("\n").append(CommunityScreen.date(entry))));
if(article&&!compact) {
height=width*9/16+78;
try {
@@ -51,7 +51,7 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
if(compact)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,y+31,accent,false);
else {
g.text(font,font.plainSubstrByWidth(str(entry,"authorName"),width-14),x+7,top+30,0xffd6d4c9,false);
g.text(font,CommunityScreen.date(entry),x+7,top+42,0xffa2aaa4,false);
g.text(font,font.plainSubstrByWidth(CommunityScreen.date(entry),width-14),x+7,top+42,0xffa2aaa4,false);
if(!article)g.text(font,font.plainSubstrByWidth(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))).getString(),width-14),x+7,top+59,accent,false);
}
}
@@ -32,13 +32,13 @@ public final class CommunityClient {
}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c) -> { pending = 0; owner = null; callback = null; overview = new JsonObject(); stale = true; overviewError = ""; DRAFTS.clear(); });
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if(++followTicks%100==0 && pending==0 && client.player!=null && client.gui.screen()==null && available())request(null,"following","","","notice","info","","",0,0,r->{});
if(++followTicks%100==0 && pending==0 && client.player!=null && (client.gui.screen()==null || client.gui.screen() instanceof SanctuaryAtlasScreen || client.gui.screen() instanceof PauseAtlasAccess) && available())request(null,"following","","","notice","info","","",0,0,r->{});
if (pending != 0 && ++ticks > 200) { var result = new JsonObject(); result.addProperty("error","unavailable"); finish(result); }
});
}
private static void finish(JsonObject result) {
pending = 0;
if(result.has("following"))NotificationClient.community(result.getAsJsonArray("following"));
if(result.has("following")){NotificationClient.community(result.getAsJsonArray("following"));CommunityMapMarkers.accept(result.getAsJsonArray("following"));}
if (overviewRequest) {
if (!result.has("error")) { overview = result; stale = false; overviewError = ""; }
else { stale = true; overviewError = str(result,"error"); }
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonArray;
import fr.koka.sanctuary.community.CommunityTask;
import java.util.*;
import java.util.function.DoubleToIntFunction;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.network.chat.Component;
/** Locations deliberately shared by followed notices; these never reveal terrain or generate chunks. */
final class CommunityMapMarkers {
record Marker(String id,String title,String author,String summary,CommunityTask task,int color) { }
record Pin(Marker marker,int x,int y) {
boolean contains(double mx,double my){return Math.abs(mx-x)<=7 && Math.abs(my-y)<=8;}
}
private static List<Marker> markers=List.of();
private CommunityMapMarkers() { }
static void clear(){markers=List.of();}
static void accept(JsonArray entries) {
var next=new ArrayList<Marker>();
for(var value:entries) {
var e=value.getAsJsonObject();
if(!CommunityClient.str(e,"status").equals("open") || !e.has("task") || e.get("task").isJsonNull())continue;
var task=new com.google.gson.Gson().fromJson(e.get("task"),CommunityTask.class);
if(task.location()!=null)next.add(new Marker(CommunityClient.str(e,"id"),CommunityClient.str(e,"title"),CommunityClient.str(e,"authorName"),CommunityClient.str(e,"summary"),task,CommunityCard.color(CommunityClient.str(e,"category"))));
}
markers=List.copyOf(next);
}
static List<Pin> project(String dimension,DoubleToIntFunction screenX,DoubleToIntFunction screenZ,int left,int top,int right,int bottom) {
var pins=new ArrayList<Pin>();
for(var marker:markers) {
var location=marker.task().location();if(!location.dimension().equals(dimension))continue;
int x=screenX.applyAsInt(location.x()+.5),y=screenZ.applyAsInt(location.z()+.5);
if(x>=left+7 && x<right-7 && y>=top+8 && y<bottom-8)pins.add(new Pin(marker,x,y));
}
return List.copyOf(pins);
}
static Pin at(List<Pin> pins,double x,double y){return pins.stream().filter(p->p.contains(x,y)).findFirst().orElse(null);}
static boolean render(GuiGraphicsExtractor g,List<Pin> pins,int mouseX,int mouseY) {
var font=Minecraft.getInstance().font;
for(var pin:pins) {
int x=pin.x(),y=pin.y();
g.fill(x-7,y-8,x+8,y+9,pin.contains(mouseX,mouseY)?0xfff4e7b4:pin.marker().color());
g.fill(x-6,y-7,x+7,y+8,0xff20251e);
g.fill(x-1,y-5,x+2,y+2,0xffffd35a);
g.fill(x-1,y+4,x+2,y+6,0xffffd35a);
}
var hovered=at(pins,mouseX,mouseY);
if(hovered==null)return false;
g.setTooltipForNextFrame(font,tooltip(hovered.marker()),mouseX,mouseY);
return true;
}
static List<net.minecraft.util.FormattedCharSequence> tooltip(Marker marker) {
var client=Minecraft.getInstance();var font=client.font;var task=marker.task();var location=task.location();
int width=Math.min(230,client.getWindow().getGuiScaledWidth()-28);
var lines=new ArrayList<net.minecraft.util.FormattedCharSequence>();
lines.addAll(font.split(Component.literal(marker.title()).withStyle(net.minecraft.ChatFormatting.BOLD),width).stream().limit(2).toList());
lines.add(Component.literal(font.plainSubstrByWidth(marker.author(),width)).getVisualOrderText());
lines.addAll(font.split(Component.literal(marker.summary()),width).stream().limit(3).toList());
lines.addAll(font.split(Component.literal("X "+location.x()+" Y "+location.y()+" Z "+location.z()),width));
if(!task.materials().isEmpty()) {
lines.add(CommunityClient.text("materials").getVisualOrderText());
itemLines(lines,task.materials(),width,2);
}
lines.addAll(font.split(CommunityClient.text("reward."+task.reward()),width));
if(task.reward().equals("items"))itemLines(lines,task.rewards(),width,2);
if(task.reward().equals("custom"))lines.addAll(font.split(Component.literal(task.customReward()),width).stream().limit(2).toList());
lines.addAll(font.split(CommunityClient.text("audience."+task.audience()),width));
int limit=Math.max(5,(client.getWindow().getGuiScaledHeight()-34)/font.lineHeight-2);
if(lines.size()>limit){lines=new ArrayList<>(lines.subList(0,limit-1));lines.add(Component.literal("…").getVisualOrderText());}
lines.addAll(font.split(CommunityClient.text("map_followed"),width));
return List.copyOf(lines);
}
private static void itemLines(List<net.minecraft.util.FormattedCharSequence> lines,List<CommunityTask.Material> items,int width,int limit) {
var font=Minecraft.getInstance().font;
for(var item:items.stream().limit(limit).toList()) {
var name=net.minecraft.core.registries.BuiltInRegistries.ITEM.getOptional(net.minecraft.resources.Identifier.parse(item.item()))
.map(i->i.getDefaultInstance().getHoverName()).orElse(Component.literal(item.item()));
lines.addAll(font.split(Component.literal(item.count()+" × ").append(name),width));
}
if(items.size()>limit)lines.addAll(font.split(CommunityClient.text("map_more_materials",items.size()-limit),width));
}
}
@@ -31,7 +31,7 @@ public final class CommunityPausePanels implements AutoCloseable {
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
add.accept(date);add.accept(clock);
int headerSide=Math.max(84,screen.width/5);
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->{});
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->open(screen,"bulletin","",false));
add.accept(bulletin);
String[] sections={"blocks","history","progress","identity"};
int optionsTop=screen.height-76;
@@ -64,7 +64,7 @@ public final class CommunityPausePanels implements AutoCloseable {
private void updateHeader() {
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
var now=ZonedDateTime.now();
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM",locale).format(now)));
date.setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy",locale).format(now)));
clock.setMessage(Component.literal(DateTimeFormatter.ofPattern("HH:mm",locale).format(now)));
clock.setX(screen.width-8-Minecraft.getInstance().font.width(clock.getMessage()));clock.setWidth(Minecraft.getInstance().font.width(clock.getMessage()));
var list=items("bulletins");
@@ -72,7 +72,10 @@ public final class CommunityPausePanels implements AutoCloseable {
if(!overviewError.isEmpty())message=text(overviewError);
bulletin.setMessage(message);
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
bulletin.active=false;
date.setWidth(Minecraft.getInstance().font.width(date.getMessage()));
int left=Math.max(date.getRight()+8,screen.width/5),right=screen.width-Math.max(56,screen.width/5);
bulletin.setRectangle(Math.max(24,right-left),32,left,3);
bulletin.active=available();
}
public void tick() {
if(closed)return;
@@ -12,6 +12,11 @@ import static fr.koka.sanctuary.client.CommunityClient.*;
/** Native community pages: user content is always a literal Minecraft component. */
public final class CommunityScreen extends BlocodexFrame {
private final String kind;
private final boolean admin;
private String search="";
private int searchAfter;
private boolean rebuildSearch;
private EditBox searchBox;
private String id, editingId = "", message = "", expandedId = "";
private int page, revision;
private boolean composing, needsLoad = true, moderator;
@@ -36,12 +41,19 @@ public final class CommunityScreen extends BlocodexFrame {
}
static CommunityScreen admin(Screen parent){return new CommunityScreen(parent,"bulletin","",false,true);}
private CommunityScreen(Screen parent,String kind,String id,boolean compose,boolean admin) {
super(parent); this.kind = kind.equals("bulletin")&&!admin?"article":kind; this.id = id; this.composing = compose;
super(parent); this.kind = kind; this.admin=admin; this.id = id; this.composing = compose && (!kind.equals("bulletin") || admin);
moderator = flag(overview,"moderator");
if (compose) useDraft("new:" + kind);
}
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
@Override protected void rebuildWidgets() {
boolean restoreFocus=searchBox!=null && searchBox.isFocused() && !composing && id.isEmpty();
int cursor=restoreFocus?searchBox.getCursorPosition():0;
super.rebuildWidgets();
if(restoreFocus && searchBox!=null){setInitialFocus(searchBox);searchBox.moveCursorTo(cursor,false);}
}
@Override protected void init() {
searchBox=null;
closePhotos();initFrame("history",text(kind)); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
var layout = LinearLayout.vertical().spacing(8);
if (!message.isEmpty()) line(layout,w,text(message));
@@ -55,9 +67,12 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void listing(LinearLayout layout,int w) {
var tabs=layout.addChild(LinearLayout.horizontal().spacing(6));
if(!kind.equals("bulletin"))for(String target:new String[]{"article","notice"})tabs.addChild(Button.builder(text(target),b->open(parent,target,"",false)).width((w-6)/2).build());
if(!admin)for(String target:new String[]{"article","notice","bulletin"})tabs.addChild(Button.builder(text(target.equals("bulletin")?"server_tab":target.equals("notice")?"notice_tab":target),b->open(parent,target,"",false)).width((w-12)/3).build());
searchBox=layout.addChild(new EditBox(font,w,20,text("search")));
searchBox.setMaxLength(120);searchBox.setHint(text("search"));searchBox.setValue(search);
searchBox.setResponder(value->{search=value;page=0;more=false;entries.clear();restoreScroll=0;needsLoad=true;searchAfter=ticks+6;rebuildSearch=true;});
var toolbar=layout.addChild(LinearLayout.horizontal().spacing(6));
button(toolbar,(w-6)/2,"publish",()->{composing=true;useDraft("new:"+kind);rebuildWidgets();});
if(!kind.equals("bulletin") || admin)button(toolbar,(w-6)/2,"publish",()->{composing=true;useDraft("new:"+kind);rebuildWidgets();});
button(toolbar,(w-6)/2,"refresh",()->{page=0;needsLoad=true;});
if(entries.isEmpty())line(layout,w,text(needsLoad?"loading":"empty"));
int cardWidth=(w-8)/2;
@@ -122,11 +137,12 @@ public final class CommunityScreen extends BlocodexFrame {
}).size(20,18).tooltip(Tooltip.create(text("actions_for",str(entry,"authorName")))).build());
}
private void message(LinearLayout layout,int w,JsonObject entry) {
author(layout,w,entry,true);
author(layout,w,entry,!kind.equals("bulletin") || admin);
layout.addChild(new CommunityBubble(w,str(entry,"body"),own(entry)));
if(expandedId.equals(str(entry,"id"))) controls(layout,w,entry);
}
private void controls(LinearLayout layout,int w,JsonObject entry) {
if(kind.equals("bulletin") && !admin)return;
boolean own = own(entry);
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
if (own) button(row,size,"edit",() -> {
@@ -193,7 +209,12 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void taskDetails(LinearLayout layout,int w,JsonObject entry) {
var task=readTask(entry);
if(task.location()!=null)line(layout,w,text("location_value",task.location().dimension(),task.location().x(),task.location().y(),task.location().z()));
if(task.location()!=null) {
line(layout,w,text("location_value",task.location().dimension(),task.location().x(),task.location().y(),task.location().z()));
var map=layout.addChild(Button.builder(text("map_show"),b->minecraft.gui.setScreen(new SanctuaryAtlasScreen(this,task.location()))).width(Math.min(w,220)).build());
map.active=minecraft.level!=null && task.location().dimension().equals(minecraft.level.dimension().identifier().toString());
if(!map.active)map.setTooltip(Tooltip.create(text("map_other_dimension")));
}
if(!task.materials().isEmpty()) {line(layout,w,text("materials"));CommunityMaterialsScreen.showItems(layout,w,task.materials());}
line(layout,w,text("audience."+task.audience()));line(layout,w,text("reward."+task.reward()));
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
@@ -229,9 +250,10 @@ public final class CommunityScreen extends BlocodexFrame {
private void button(LinearLayout layout,int w,String key,Runnable action) {
layout.addChild(Button.builder(text(key),b -> { if (!publishing) action.run(); }).width(w).build());
}
static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
@Override public void tick() {
super.tick(); ticks++;
if(rebuildSearch){rebuildSearch=false;rebuildWidgets();}
if(publishing && !pending() && ticks>=sendAfter) {
if(!taskSent) {
request(this,"task",editingId.isEmpty()?draft.operation:editingId,"","notice","info","",new GsonBuilder().disableHtmlEscaping().create().toJson(draft.task),0,0,result->{
@@ -248,7 +270,9 @@ public final class CommunityScreen extends BlocodexFrame {
});
} else if(commit!=null) {var actionToRun=commit;commit=null;actionToRun.run();}
}
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","","",0,page,result -> {
String requestedSearch=search,requestedId=id;int requestedPage=page;
if (!composing && needsLoad && ticks>=searchAfter && ticks%4==0 && request(this,id.isEmpty()?(kind.equals("bulletin")?"list":"cards"):"thread",id,"",kind,"info","",id.isEmpty()?search:"",0,page,result -> {
if(!search.equals(requestedSearch) || !id.equals(requestedId) || page!=requestedPage || composing)return;
if (result.has("error")) message=str(result,"error");
else {data=result;moderator=flag(result,"moderator");
if(id.isEmpty()&&result.has("page")) {
@@ -61,7 +61,7 @@ public final class CommunityScreenshotsScreen extends BlocodexFrame {
}
@Override protected void init() {
closeTextures();initFrame("history",text("screenshots"));
contentWidth=Math.min(420,contentWidth);contentX=(width-contentWidth)/2;
contentWidth=Math.max(240,width-24);contentX=(width-contentWidth)/2;
if(!loaded)load(true);
if(selected!=null) {preview();return;}
int w=contentWidth-24;
@@ -70,12 +70,12 @@ public final class CommunityScreenshotsScreen extends BlocodexFrame {
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
controls.setPosition(contentX+8,contentTop+Math.max(24,Math.min(180,contentBottom-contentTop-28))+8);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
controls.setPosition(contentX+8,contentTop+Math.max(24,contentBottom-contentTop-28)+8);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
var layout=LinearLayout.vertical().spacing(8);
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
else {
int columns=w>=360?4:3,cell=(w-(columns-1)*8)/columns;
int columns=Math.max(2,w/112),cell=(w-(columns-1)*8)/columns;
for(int i=0;i<shots.size();i+=columns) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
@@ -85,11 +85,11 @@ public final class CommunityScreenshotsScreen extends BlocodexFrame {
}
}
}
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,Math.min(180,contentBottom-contentTop-28)),ScrollableLayout.ReserveStrategy.RIGHT);
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=Math.min(260,contentWidth-16),h=Math.max(24,Math.min(146,contentBottom-contentTop-34));
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
int left=(width-w)/2;
try {
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
@@ -41,7 +41,7 @@ public final class NotificationClient {
ClientPlayConnectionEvents.DISCONNECT.register((h,m)->reset());
ClientTickEvents.END_CLIENT_TICK.register(NotificationClient::tick);
}
public static void reset(){communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void reset(){CommunityMapMarkers.clear();communityTracks=List.of();FEED.clear();OBJECTIVES.reset();inhabitant=null;trackingScope=null;hasRoom=true;recipes=null;blocks=totalBlocks=totalItems=totalRecipes=ticks=0;lastClock=0;animatedBlocks=animatedItems=animatedRecipes=0;}
public static void clearFeed(){FEED.clear();}
public static List<NotificationFeed.Entry<Message>> messages(){return FEED.entries();}
public static List<Track> tracked(){var tracks=new ArrayList<>(OBJECTIVES.shown(options().pinnedAdvancements,options().advancements));if(options().pinnedAdvancements)tracks.addAll(communityTracks);return tracks.stream().distinct().limit(6).toList();}
@@ -69,6 +69,16 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
if(--refresh<=0)request();
}
public int knownPixels() {return pixels;}
private double zoom(){return Math.max(.75,Math.max(width,height)/992.);}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,wx->getX()+width/2+(int)Math.round((wx-centerX)*zoom()),
wz->getY()+height/2+(int)Math.round((wz-centerZ)*zoom()),getX(),getY()+20,getRight(),getBottom()-20);
}
@Override public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event,boolean doubleClick) {
var pin=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT && pin!=null){CommunityClient.open(owner,"notice",pin.marker().id(),false);return true;}
return super.mouseClicked(event,doubleClick);
}
@Override public void onPress(InputWithModifiers input) {ProgressionClient.open(owner,"history");}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(texture==null)return;
@@ -77,7 +87,7 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
g.fill(x,y,getRight(),getBottom(),0x90151B19);
g.enableScissor(x,y,getRight(),getBottom());
if(readable) {
double zoom=Math.max(.75,Math.max(width,height)/992.);
double zoom=zoom();
int left=x+width/2+(int)((originX-centerX)*zoom),top=y+height/2+(int)((originZ-centerZ)*zoom);
g.blit(RenderPipelines.GUI_TEXTURED,TEXTURE,left,top,0,0,(int)(SIZE*zoom),(int)(SIZE*zoom),SIZE,SIZE,SIZE,SIZE);
int px=x+width/2,pz=y+height/2;
@@ -89,6 +99,8 @@ public final class PauseAtlasWidget extends AbstractButton implements AutoClosea
int color=ProgressionClient.identity==null?0xFFFFFFFF:0xFF000000|ProgressionClient.identity.color();
g.fill(px-3,pz-3,px+4,pz+4,0xFF141818);g.fill(px-2,pz-2,px+3,pz+3,color);
}
boolean questHovered=CommunityMapMarkers.render(g,questPins(),mx,my);
setTooltip(questHovered?null:Tooltip.create(Component.translatable("sanctuary.pause.map_hint")));
g.text(font,"N ↑",x+7,y+7,0xFFFFFFFF);
if(pixels==0)g.textWithWordWrap(font,SanctuaryAtlasScreen.label(readable?"empty":status),x+8,y+height/2+12,width-16,0xFFCCCCCC);
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-14),x+7,getBottom()-16,0xFFF0EEE6);
@@ -38,7 +38,9 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
private String status="loading";
private String dimension="";
public SanctuaryAtlasScreen(Screen parent) {super(parent);}
private final fr.koka.sanctuary.community.CommunityTask.Location initialTarget;
public SanctuaryAtlasScreen(Screen parent) {this(parent,null);}
SanctuaryAtlasScreen(Screen parent,fr.koka.sanctuary.community.CommunityTask.Location target) {super(parent);initialTarget=target;}
public static Component label(String key,Object... args) {return Component.translatable("sanctuary.atlas."+key,args);}
@Override protected void init() {
dragging=false;
@@ -75,7 +77,10 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
minecraft.getTextureManager().register(TEXTURE,texture);
footprintTexture=new DynamicTexture("Sanctuary atlas footprints",SIZE,SIZE,false);
minecraft.getTextureManager().register(FOOTPRINT_TEXTURE,footprintTexture);
clear();recenter();
clear();
if(initialTarget!=null && minecraft.level!=null && initialTarget.dimension().equals(minecraft.level.dimension().identifier().toString())) {
centerX=initialTarget.x()+.5;centerZ=initialTarget.z()+.5;request();
} else recenter();
}
}
private void clear() {
@@ -193,6 +198,8 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
@Override public boolean mouseClicked(MouseButtonEvent event,boolean doubleClick) {
// Disabled buttons also own their hit area; clicking one must never start a map gesture.
if (overControl(event.x(),event.y())) return super.mouseClicked(event,doubleClick);
var quest=CommunityMapMarkers.at(questPins(),event.x(),event.y());
if(event.button()==InputConstants.MOUSE_BUTTON_LEFT && quest!=null){CommunityClient.open(this,"notice",quest.marker().id(),false);return true;}
if (event.button()==InputConstants.MOUSE_BUTTON_LEFT && inside(event.x(),event.y()) && readable()) {
setFocused(null);setDragging(false);dragging=true;return true;
}
@@ -267,13 +274,18 @@ public final class SanctuaryAtlasScreen extends BlocodexFrame {
graphics.textWithWordWrap(font,message,x,y,textWidth,0xFFE0E6EE);
}
renderCoordinates(graphics,mouseX,mouseY);
if (!renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
boolean questHint=CommunityMapMarkers.render(graphics,questPins(),mouseX,mouseY);
if (!questHint && !renderMarkerHint(graphics,mouseX,mouseY)) renderFootprintHint(graphics,mouseX,mouseY);
}
public void accept(fr.koka.sanctuary.atlas.AtlasMarkerPayload payload) {
if (payload.token()!=token || !readable() || minecraft.level==null
|| !dimension.equals(minecraft.level.dimension().identifier().toString())) return;
markers=payload.markers();
}
java.util.List<CommunityMapMarkers.Pin> questPins() {
return CommunityMapMarkers.project(dimension,this::screenX,this::screenZ,0,contentTop,width,height-62).stream()
.filter(pin->!overControl(pin.x(),pin.y())).toList();
}
private Component markerName(fr.koka.sanctuary.atlas.AtlasMarker marker) {
return marker.name().isBlank() ? label("marker_"+marker.kind()) : Component.literal(marker.name());
}
@@ -44,6 +44,12 @@ public final class Community {
public Refused(String code) { super(code); this.code = code; }
}
public static void fail(String code) { throw new Refused(code); }
public static String search(String value) {
if(value==null || value.codePointCount(0,value.length())>120)fail("invalid");
value=value.strip();
if(!value.isEmpty())text(value,120);
return value.toLowerCase(Locale.ROOT);
}
public static String uuid(String value) {
if (value == null || !value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) fail("invalid");
return value;
@@ -79,17 +79,17 @@ public final class CommunityNetwork {
if (System.nanoTime() - now > 10_000_000_000L) fail("busy");
var store = runtime.store(); result = new JsonObject(); result.addProperty("moderator",actor.moderator());
result.addProperty("serverTime",System.currentTimeMillis()); result.addProperty("mode",runtime.config.mode());
if(request.kind().equals("bulletin") && !Set.of("overview","following").contains(request.action()) && !actor.moderator())fail("forbidden");
if(request.kind().equals("bulletin") && !Set.of("overview","following","list","cards","thread").contains(request.action()) && !actor.moderator())fail("forbidden");
switch (request.action()) {
case "overview" -> {
result.add("articles", JSON.toJsonTree(store.list("article","",0).entries().stream().limit(3).toList()));
result.add("notices", JSON.toJsonTree(store.list("notice","",0).entries().stream().limit(3).toList()));
result.add("bulletins", JSON.toJsonTree(store.list("bulletin","",0).entries().stream().limit(1).toList()));
}
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page(),request.body())));
case "cards" -> {
if(!Set.of("article","notice").contains(request.kind()))fail("invalid");
var page=store.list(request.kind(),"",request.page());var cards=JSON.toJsonTree(page).getAsJsonObject();
var page=store.list(request.kind(),"",request.page(),request.body());var cards=JSON.toJsonTree(page).getAsJsonObject();
for(var value:cards.getAsJsonArray("entries")) {
var entry=value.getAsJsonObject();String photo=entry.has("photo")?entry.get("photo").getAsString():"";
if(!photo.isEmpty())entry.addProperty("thumbnail",runtime.thumbnail(entry.get("id").getAsString()+":"+entry.get("revision").getAsInt(),photo));
@@ -99,7 +99,6 @@ public final class CommunityNetwork {
}
case "thread" -> {
var thread=store.thread(request.id(),request.page());
if(thread.entry().kind().equals("bulletin")&&!actor.moderator())fail("forbidden");
result.add("thread",JSON.toJsonTree(thread));
if(thread.entry().kind().equals("notice")) {
var members=store.members(request.id());result.add("members",JSON.toJsonTree(members));
@@ -154,7 +153,11 @@ public final class CommunityNetwork {
}
private static JsonElement following(CommunityStore store,Actor actor) throws Exception {
var result=JSON.toJsonTree(store.following(actor));omitPhotos(result);
result.getAsJsonArray().forEach(e->e.getAsJsonObject().remove("body"));return result;
result.getAsJsonArray().forEach(e->{
var entry=e.getAsJsonObject();String body=entry.remove("body").getAsString();
int count=body.codePointCount(0,body.length());
entry.addProperty("summary",count>240?body.substring(0,body.offsetByCodePoints(0,240))+"…":body);
});return result;
}
private static void omitTasks(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitTasks);
@@ -9,7 +9,8 @@ public interface CommunityStore extends AutoCloseable {
void save(Entry entry, boolean insert) throws Exception;
int followers(String id) throws Exception;
}
Page list(String kind, String parent, int page) throws Exception;
default Page list(String kind, String parent, int page) throws Exception { return list(kind,parent,page,""); }
Page list(String kind, String parent, int page, String search) throws Exception;
ThreadView thread(String id, int page) throws Exception;
Entry change(Actor actor, Change change) throws Exception;
java.util.List<CommunityTask.Follow> members(String id) throws Exception;
@@ -40,20 +40,22 @@ public final class FileCommunityStore implements CommunityStore {
}
}
}
@Override public synchronized Page list(String kind, String parent, int page) {
@Override public synchronized Page list(String kind, String parent, int page, String search) {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(entries.get(parent));
Comparator<Entry> order = Comparator.comparingLong(Entry::createdAt).thenComparing(Entry::id);
if (kind.equals("bulletin")) order = order.reversed();
else if (!kind.equals("reply")) order = Comparator.comparing(Entry::pinned).reversed().thenComparing(order.reversed());
var found = entries.values().stream().filter(e -> e.kind().equals(kind) && e.parentId().equals(parent)
&& !e.status().equals("hidden")).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
&& !e.status().equals("hidden") && (needle.isEmpty() || e.authorName().toLowerCase(Locale.ROOT).contains(needle)
|| e.title().toLowerCase(Locale.ROOT).contains(needle) || e.body().toLowerCase(Locale.ROOT).contains(needle))).sorted(order).skip((long) page * PAGE_SIZE).limit(PAGE_SIZE + 1).toList();
return new Page(found.subList(0, Math.min(PAGE_SIZE, found.size())), page, found.size() > PAGE_SIZE);
}
@Override public synchronized ThreadView thread(String id, int page) {
Entry entry = visible(entries.get(uuid(id)));
if (entry.kind().equals("reply")) fail("missing");
return new ThreadView(entry, list("reply", id, page));
return new ThreadView(entry, list("reply", id, page, ""));
}
@Override public synchronized Entry change(Actor actor, Change change) throws Exception {
var candidate = new LinkedHashMap<>(entries);
@@ -42,27 +42,30 @@ public final class SqlCommunityStore implements CommunityStore {
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
var r = s.executeQuery()) { return r.next() ? row(r) : null; }
}
private Page list(Connection c, String kind, String parent, int page) throws Exception {
private Page list(Connection c, String kind, String parent, int page, String search) throws Exception {
String needle=Community.search(search);
page(page); if (!KINDS.contains(kind)) fail("invalid");
if (!parent.isEmpty()) visible(get(c, uuid(parent), false));
String parentSql = parent.isEmpty() ? "parent_id IS NULL" : "parent_id=?";
String order = kind.equals("reply") ? "created_at ASC,id ASC" : kind.equals("bulletin") ? "created_at DESC,id DESC" : "pinned DESC,created_at DESC,id DESC";
var values = new ArrayList<Object>(List.of(serverId, kind)); if (!parent.isEmpty()) values.add(parent);
String filter=needle.isEmpty()?"":" AND (LOCATE(?,BINARY LOWER(author_name))>0 OR LOCATE(?,BINARY LOWER(title))>0 OR LOCATE(?,BINARY LOWER(body))>0)";
if(!needle.isEmpty())values.addAll(List.of(needle,needle,needle));
values.add(PAGE_SIZE + 1); values.add(page * PAGE_SIZE);
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND kind=? AND " + parentSql
+ " AND status<>'hidden' ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
+ " AND status<>'hidden'" + filter + " ORDER BY " + order + " LIMIT ? OFFSET ?", values.toArray()); var r = s.executeQuery()) {
var entries = new ArrayList<Entry>(); while (r.next()) entries.add(row(r));
return new Page(List.copyOf(entries.subList(0, Math.min(PAGE_SIZE, entries.size()))), page, entries.size() > PAGE_SIZE);
}
}
@Override public Page list(String kind, String parent, int page) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page); c.commit(); return result; }
@Override public Page list(String kind, String parent, int page, String search) throws Exception {
try (var c = connect()) { c.setAutoCommit(false); var result = list(c, kind, parent, page, search); c.commit(); return result; }
}
@Override public ThreadView thread(String id, int page) throws Exception {
try (var c = connect()) {
c.setAutoCommit(false); Entry entry = visible(get(c, uuid(id), false));
if (entry.kind().equals("reply")) fail("missing");
var result = new ThreadView(entry, list(c, "reply", id, page)); c.commit(); return result;
var result = new ThreadView(entry, list(c, "reply", id, page, "")); c.commit(); return result;
}
}
@Override public Entry change(Actor actor, Change change) throws Exception {
@@ -2341,6 +2341,13 @@
"sanctuary.shaders.ssr_distance_help": "Independent SSR range: 16, 32, 64, 128 or 256 blocks. Default: 64 blocks. Limited to visible terrain and mesh budget.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Notice board",
"sanctuary.community.notice_tab": "Notice board",
"sanctuary.community.map_followed": "Followed request · click for discussion",
"sanctuary.community.map_more_materials": "+ %s more materials",
"sanctuary.community.map_show": "Show on map",
"sanctuary.community.map_other_dimension": "Visit this dimension to open its map.",
"sanctuary.community.search": "Search: player, title or text…",
"sanctuary.community.server_tab": "Server",
"sanctuary.community.bulletin": "Server notices",
"sanctuary.community.publish": "Publish",
"sanctuary.community.reply": "Reply",
@@ -2341,6 +2341,13 @@
"sanctuary.shaders.ssr_distance_help": "Portée indépendante du SSR : 16, 32, 64, 128 ou 256 blocs. Défaut : 64 blocs. Limitée au terrain visible et au budget de maillage.",
"sanctuary.community.article": "Gazette",
"sanctuary.community.notice": "Tableau",
"sanctuary.community.notice_tab": "Tableau",
"sanctuary.community.map_followed": "Demande suivie · cliquer pour la discussion",
"sanctuary.community.map_more_materials": "+ %s autres matériaux",
"sanctuary.community.map_show": "Voir sur la carte",
"sanctuary.community.map_other_dimension": "Rejoignez cette dimension pour ouvrir sa carte.",
"sanctuary.community.search": "Rechercher : joueur, titre ou texte…",
"sanctuary.community.server_tab": "Serveur",
"sanctuary.community.bulletin": "Intendance",
"sanctuary.community.publish": "Publier",
"sanctuary.community.reply": "Répondre",
@@ -50,6 +50,28 @@ public final class CommunitySmoke {
check(new com.google.gson.GsonBuilder().disableHtmlEscaping().create().toJson(task).length()<8000,"Task fits request envelope");
System.out.println("COMMUNITY158_TASKS_PASS "+store.getClass().getSimpleName());return limited;
}
static void search(CommunityStore store) throws Exception {
var author=new Actor(UUID.randomUUID().toString(),"Chercheur_159",false);
Entry first=null;
for(int i=0;i<13;i++) {
var e=store.change(author,create(UUID.randomUUID().toString(),"","notice","Recherche159 "+i,i==0?"Épingle 50%_exact":"Description159"));
if(i==0)first=e;
}
var pinned=store.change(moderator,action("pin",first));
check(store.list("notice","",0," CHERCHEUR_159 ").entries().getFirst().id().equals(pinned.id()),"Search author and retain pins");
check(store.list("notice","",0,"recherche159").more()&&store.list("notice","",1,"recherche159").entries().size()==3,"Search before pagination");
check(store.list("notice","",0,"50%_exact").entries().size()==1,"Literal SQL wildcard characters");
check(store.list("notice","",0,"ÉPINGLE").entries().size()==1,"Accented case-insensitive body search");
check(store.list("notice","",0,"%").entries().size()==1,"Percent is literal");
check(store.list("article","",0,"Chercheur_159").entries().isEmpty(),"Search stays within selected section");
store.change(moderator,action("hide",pinned));
check(store.list("notice","",0,"50%_exact").entries().isEmpty(),"Hidden search result excluded");
refuse("invalid",()->store.list("notice","",0,"x".repeat(121)));
var official=store.change(moderator,create(UUID.randomUUID().toString(),"","bulletin","Serveur159","Message officiel"));
check(store.list("bulletin","",0,"serveur159").entries().size()==1 && store.thread(official.id(),0).entry().equals(official),"Read server history");
refuse("forbidden",()->store.change(owner,action("hide",official)));
System.out.println("COMMUNITY159_SEARCH_PASS "+store.getClass().getSimpleName());
}
static Entry scenario(CommunityStore store) throws Exception {
var request=create(UUID.randomUUID().toString(),"","notice","Un pont","Du bois et des bras 🌳");
var post=store.change(owner,request);
@@ -92,7 +114,7 @@ public final class CommunitySmoke {
Path dir=Files.createTempDirectory("sanctuary-community-check-");
String server=UUID.randomUUID().toString();Path file=dir.resolve("community.json");
var store=new FileCommunityStore(file,server);var article=scenario(store);
var follower=tasks(store);
var follower=tasks(store);search(store);
var reopened=new FileCommunityStore(file,server);
check(reopened.following(follower).equals(store.following(follower)) && reopened.following(follower).size()==16,"Follow state survives restart");
check(reopened.thread(article.id(),0).entry().equals(article),"Durable restart");
@@ -102,7 +124,7 @@ public final class CommunitySmoke {
String url=System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC");
if(url!=null){
var sql=new SqlCommunityStore(url,"root","",server);
var value=scenario(sql);tasks(sql);
var value=scenario(sql);tasks(sql);search(sql);
check(new SqlCommunityStore(url,"root","",server).thread(value.id(),0).entry().equals(value),"Database restart");
var isolated=new SqlCommunityStore(url,"root","",UUID.randomUUID().toString());
check(isolated.list("notice","",0).entries().isEmpty(),"Different server isolated");
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.158"
version = "beta.159"
pack-format = "packwiz:1.1.0"
[index]