Compare commits

...
4 Commits
Author SHA1 Message Date
koka 9a429c40a1 Align pause calendar and server bulletin for beta.164
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:56:27 +02:00
koka 905b3bcfa1 Reshape pause navigation and frame for beta.163
Build Sanctuary / build (push) Waiting to run
2026-09-24 13:36:56 +02:00
koka 0c55a37562 Deliver beta.162 notice author scene and side conversation
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:38:09 +02:00
koka 8deba7cfb4 Deliver beta.161 playtest fixes and shared server metrics
Build Sanctuary / build (push) Waiting to run
2026-09-24 00:20:06 +02:00
25 changed files with 730 additions and 60 deletions
+25
View File
@@ -1,5 +1,30 @@
# Sanctuary
## beta.164 — calendrier et intendance
Date et heure réunies à gauche ; titre serveur aligné sur la carte et les panneaux.
[Résultat et vérifications](docs/pause-calendar-beta164.md).
## beta.163 — menu pause encadré
Calendrier, intendance et horloge en en-tête ; deux groupes de navigation,
séparateurs natifs et reprise centrée dans le footer.
[Résultat et vérifications](docs/pause-frame-beta163.md).
## beta.162 — tableau en scène et conversation
Portrait de l’auteur tourné vers la souris, bulle aux couleurs de l’annonce,
demande à gauche et réponses à droite. Retour/Actualiser restent en haut ;
la conversation passe dessous sur les GUI étroits.
[Résultat et vérifications](docs/notice-scene-beta162.md).
## beta.161 — correctifs de séance et site vivant
Liste des joueurs sur U, suivi des demandes expliqué, intendance compacte et
compteurs réels partagés avec le site. Site public en lecture seule, Gazette
en accordéon avec aperçus compacts.
[Contrat et vérifications](docs/session-fixes-beta161.md).
## beta.160 — laboratoire de test en duo
Shader désactivé sur une configuration neuve, serveur local léger, personnages
+66
View File
@@ -0,0 +1,66 @@
# beta.162 — scène du tableau et conversation
Ticket issu du retour de séance du 24 septembre : conserver Retour/Actualiser en
haut, montrer l’auteur avec son skin orienté vers la souris et sa demande dans
une bulle colorée, puis les réponses à droite (dessous sur GUI étroit).
Modification client uniquement, sans migration, écriture dans les mondes ni
changement des contrats fichier/MariaDB. Réponses, modération et suivi gardent
leurs validations serveur. Le portrait est un rendu GUI, pas une entité.
## Présentation
- Retour et Actualiser restent hors de la zone défilante.
- Le portrait utilise le skin de la connexion de l’auteur s’il est présent,
sinon le cache de profils Minecraft asynchrone. Le skin natif de repli apparaît
pendant le chargement ou si le profil ne peut pas être résolu (notamment les
comptes fictifs/hors ligne). Aucun téléchargement synchrone dans le rendu.
- Une bulle persistante encadrée montre catégorie/état, titre puis texte complet.
Information : jaune clair sur fond sombre jaune ; les autres catégories gardent
leurs couleurs. Les cartes de liste et du menu pause suivent aussi ce jaune.
- Le portrait tourne doucement dans des angles bornés selon la position de la
souris, sans capturer ses mouvements et sans clic requis.
- Demande et discussion défilent séparément si l’espace le permet ; le champ de
réponse et son bouton restent en bas de la discussion. Sur GUI étroit/bas,
un défilement unique garde les réponses sous la scène.
- Matériaux, lieu, récompense, suivi, visages des réponses et modération sont
conservés. Les détails d’une demande vide ne sont plus affichés artificiellement.
- Aucune animation de parole ni synchronisation vocale : « parle » désigne ici
la mise en scène visuelle avec la bulle.
## Vérification
- Compilation Java 25 / Minecraft 26.3 réussie.
- Parcours natif Vulkan : `NOTICE162_SCENE_PASS`, puis
`SEARCH159_CLIENT_PASS mode=file`, build réussi en 1 min 59 s.
- FR/EN, GUI 2/3/4 : scène, passage en colonne unique, boutons de retour au-dessus,
réponse au-dessus du footer en deux colonnes, angles du portrait bornés et
changement d’orientation avec la souris.
- Brouillon conservé pendant les redimensionnements ; publication d’une réponse
et lecture de son contenu après rechargement serveur vérifiées.
- Régressions communautaires : photos, recherche, suivi/désabonnement, marqueur
de carte et infobulle, restrictions de l’Intendance toujours validés.
- Captures relues : `build/qa-beta162/0042_notice162-scene-fr_fr-2.png` et
`0043_notice162-scene-fr_fr-3.png`. Conversation : `0048_notice162-conversation.png`.
- `./gradlew check build assemblePack` lancé : 252 GameTests terminés, 229 réussis
et 23 échecs, exactement les mêmes qu’en beta.161 (aucun ajouté/retiré).
Liste comparée : `build/session162-server-failures.json`.
- Contrôles restants et assemblage avec exclusion ponctuelle de `runGameTest` :
réussis en 3 min 16 s (`check build assemblePack -x :sanctuary:runGameTest`).
Aucune assertion désactivée dans les sources. Log : `build/beta162-assembly.log`.
Aucune modification de persistance : cette passe native utilise le mode fichier ;
le laboratoire MariaDB partagé a été relancé avec la même interface, KokaLab
connecté sur le monde existant `duo-flat-160`, Alice/Bob recréés, shader désactivé. Les figurants
et profils hors ligne peuvent afficher un skin Minecraft de repli.
## Livraison locale
JAR : `mods/sanctuary/build/libs/sanctuary-beta.162.jar`.
Pack assemblé : `build/packwiz`.
SHA-256 : `35c5d82eba110ee2150f73baf6341efc90f87971965a3b4dd2bcd04add0a59ff`.
Branche `codex/notice-scene-beta162`, tag local `beta.162`. Anciens JAR conservés.
Aucune publication distante ni synchronisation de Prism dans cette livraison.
Les 23 échecs préexistants restent ouverts ; les essais graphiques ne démontrent
pas la résolution réseau d’un skin officiel absent dans ce laboratoire hors ligne.
+25
View File
@@ -0,0 +1,25 @@
# beta.164 — calendrier compact et intendance alignée
Calendrier à la largeur de la navigation : jour agrandi et heure sur la première
ligne, mois et année ensemble dessous. Le titre serveur est aligné à gauche
et couvre la carte ainsi que les panneaux Gazette/Tableau.
Interface cliente uniquement. Aucun changement de stockage ou de monde.
Vérification client Vulkan réussie : FR/EN aux échelles GUI 2, 3 et 4.
Le test contrôle la largeur du calendrier, l’heure dans son libellé accessible,
les limites de l’intendance et les interactions communautaires existantes.
Captures conservées dans `build/qa-beta164/` et journal dans
`build/beta164-client-retry.log`. Inspection visuelle FR GUI 4 et EN GUI 2 effectuée.
Le premier test a détecté que StringWidget redimensionnait le calendrier lors
du changement de texte ; la largeur de navigation est maintenant réappliquée.
`check build assemblePack --continue` exécuté : les mêmes 23 GameTests qu’en
beta.163 échouent, aucun nouvel échec (comparaison enregistrée dans
`build/session164-server-failures.json`). La suite complète reste en échec.
L’assemblage séparé `build assemblePack :sanctuary-test:exportDuoLaunch
-x :sanctuary:check` réussit après ce contrôle.
Anciennes archives conservées ; aucune publication distante.
JAR : `mods/sanctuary/build/libs/sanctuary-beta.164.jar`.
SHA-256 : `5bdfb097004a7932a075fbce9013556266f76ddd491ca21a24e12609c8600e53`.
+27
View File
@@ -0,0 +1,27 @@
# beta.163 — cadre du menu pause
Retour de séance : Découverte/Progression séparés de Habitant/Factions/Combat,
bouton Monde retiré, séparateurs natifs de Progression, reprise centrée dans le
footer, calendrier à gauche, message serveur au centre et horloge à droite.
Interface cliente uniquement, sans migration ni modification de règles ou de
mondes. « Options du monde » reste dans les réglages en bas à gauche ; seul le
raccourci de navigation Monde disparaît. La carte centrale reste disponible.
Vérifications :
- Test client natif Vulkan réussi : FR/EN aux échelles GUI 2, 3 et 4,
calendrier, séparation des groupes, limites des widgets et reprise centrée.
Captures dans `build/qa-beta163/` ; journal `build/beta163-client-retry.log`.
- Le scénario communautaire vérifie aussi la scène du tableau et les échanges.
Une première exécution a expiré pendant une publication ; le test respecte
désormais le délai serveur entre deux écritures.
- `check build assemblePack --continue` : 23 échecs GameTest, ensemble identique
à beta.162, aucun nouvel échec. Les autres tâches ont terminé ; la suite
complète reste donc en échec. Comparaison dans `build/session163-server-failures.json`.
- Assemblage réussi avec `build assemblePack :sanctuary-test:exportDuoLaunch
-x :sanctuary:check`, après la vérification complète.
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.163.jar`.
SHA-256 : `72df10885d1bcb3b623c46bddf361839c23f06dfd543119f91ac5e006a8f961e`.
Aucune publication distante ; anciens JAR conservés.
+78
View File
@@ -0,0 +1,78 @@
# beta.161 — correctifs de la séance du 23 septembre
Ticket : R002–R009 du carnet de séance. Branches `codex/session-fixes-beta161`.
## Contrat de données et migration additive
Aucun monde ni chunk n’est régénéré, aucun format existant n’est modifié.
Le nouveau fichier `data/sanctuary-server-metrics.json` version 1 conserve le temps
réel cumulé depuis l’activation du compteur, les morts connues par UUID et les
UUID des figurants exclus. Au premier démarrage, les morts sont importées des
statistiques Minecraft déjà présentes ; le temps antérieur n’est pas inventé.
Les valeurs vivantes sont relevées côté serveur, sauvegardées toutes les 30 secondes
et à l’arrêt. Les arrêts ne comptent pas dans la durée. Un crash peut perdre au
plus la période depuis le dernier relevé enregistré. Un fichier corrompu ou
modifié extérieurement arrête la collecte sans le remplacer.
En mode base, appliquer la migration Web additive `create_sanctuary_server_metrics_table`
avant de lancer le mod. Elle crée une table indépendante, une ligne par server_id ;
le schéma communautaire v3 et ses données restent inchangés. Le serveur publie un
instantané toutes les 30 secondes et un état arrêté à l’arrêt propre. En cas de
panne SQL le compteur local continue ; le site marque la dernière mesure périmée
après 90 secondes. Une table absente ne bloque pas les publications communautaires.
La suppression de cette nouvelle table au rollback perd uniquement les instantanés,
recréés depuis le fichier local à la prochaine publication. Ne pas supprimer le fichier
local pour revenir en arrière ; les anciennes versions l’ignorent.
Un seul serveur actif par server_id, comme pour les publications. Les morts de tous
les joueurs ayant des statistiques sont cumulées, y compris les joueurs déconnectés.
Les figurants du laboratoire sont exclus des morts et des joueurs en ligne.
## Résultat livré
Liste des joueurs sur U (migration unique de Tab ; autres choix conservés), suivi
et place unique expliqués dans les demandes. Site : retour au dessin d’origine,
intendance limitée au titre, page officielle avec retour, pas de navbar de rubriques
ni de sélecteur de langue ni d’incitation à se connecter dans les publications.
Gazette en accordéon exclusif : le dernier article visible est ouvert au chargement,
ouvrir un autre referme le précédent. Photo entière à gauche, extrait court à droite,
visage/pseudo/date et lien de lecture alignés en bas. Calendrier jour/mois encadré,
message officiel à droite, sans heure ; année dans le footer.
Les pages publiques du site sont en lecture seule, y compris leurs anciennes
routes d’écriture pour les comptes connectés et le staff. L’administration
Intendance réservée au staff reste disponible. Les joueurs publient, répondent
et suivent les demandes dans Minecraft. Compteurs réels et état périmé explicite.
## Vérifications
| Vérification | Résultat |
| --- | --- |
| `./gradlew check build assemblePack` | 252 GameTests : 229 réussis, les mêmes 23 échecs que beta.160, aucun nouvel échec dans cette passe. Comparaison dans `build/session161-server-failures.json`. |
| Contrôles JVM et persistance fichier/SQL | Réussis, dont `SERVER_METRICS161_PASS` et `COMMUNITY154_SQL_PASS` dans `build/beta161-release.log`. |
| Démarrage et raccourci U | `SESSION161_KEYS_PASS` : défaut U, migration unique depuis Tab, choix personnalisé conservé. |
| Parcours natif communautaire | `SEARCH159_CLIENT_PASS mode=file` : photos/galerie, FR/EN, GUI 2/3/4, recherche, bulletin en lecture seule, suivi et infobulle du repère. Captures dans `mods/sanctuary/build/run/clientGameTest/screenshots/`. |
| Web SQLite et MariaDB isolée | 29 tests, 252 assertions réussies ; lecture seule visiteurs/joueurs/staff, contrats de persistance, statistiques absentes/périmées/arrêtées. Pint et `git diff --check` réussis. |
| Navigateur | Accordéon exclusif, lien vers l’article, calendrier sans heure, visage/auteur/date et lecture en bas ; vérification desktop et 390 × 844. |
| Assemblage final | `./gradlew build assemblePack :sanctuary-test:exportDuoLaunch -x :sanctuary:check` réussi, après les vérifications ci-dessus. Exclusion ponctuelle des tests connus en échec, pas de suppression d’assertion. |
| Laboratoire réel | Monde existant `duo-flat-160` relancé, serveur 768 MiB/client 2048 MiB, shaders désactivés. KokaLab non OP, 26 niveaux conservés. Deux figurants, compteur SQL et Web : 1 humain. Journal de durée persisté et exclusions vérifiées. |
Le premier démarrage de test a révélé une mauvaise déclaration du nouveau mixin :
`PlayerListKeyMixin` au lieu de `client.PlayerListKeyMixin`. Elle est corrigée ;
la classe et sa déclaration sont présentes dans le JAR final. Le test natif a
ensuite réussi. L’assemblage avait redéclenché une seconde passe de GameTests
serveur ; cette répétition a été interrompue, la première passe complète faisant
foi. Logs distincts : `beta161-check-build.log`, `beta161-release.log`,
`beta161-client.log`, `beta161-assembly.log`, sous `build/`.
Livraison locale uniquement : `mods/sanctuary/build/libs/sanctuary-beta.161.jar`
et pack assemblé dans `build/packwiz`. SHA-256 du JAR :
`c6e0424bf091c41827730bc260dad3b95b7c949fb0dcc41811f0ae3d94bce79c`.
Les 29 ressources shader sont identiques à beta.160. Les anciens JAR beta.154–160
sont conservés. Aucun canal public ni instance Prism personnelle n’a été modifié.
Le téléchargement public du site reste l’ancien lien beta.151, hors de cette
livraison locale. Les 23 échecs préexistants restent ouverts.
Le compteur de jours débute à l’activation de cette version : le fonctionnement
antérieur n’était pas mesuré. Le site reçoit les nouveaux compteurs au rechargement
de la page. La mesure d’une mort réelle supplémentaire n’a pas été provoquée sur
le personnage de la séance ; l’import et l’incrément sont couverts par le test du
journal et les tests de lecture Web.
+2 -2
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.160
pack_version=beta.160
mod_version=beta.164
pack_version=beta.164
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+11
View File
@@ -131,6 +131,9 @@ tasks.named('processGametestResources') {
def defaults160 = providers.gradleProperty('sanctuaryDefaults160ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('defaults160', defaults160)
if (defaults160) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.ShaderDefaults160ClientChecks') }
def session161 = providers.gradleProperty('sanctuarySession161ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySession161ClientTests', session161)
if (session161) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Session161ClientChecks') }
def bloom130Client = providers.gradleProperty('sanctuaryBloom130ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBloom130ClientTests', bloom130Client)
if (bloom130Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.Bloom130ClientChecks') }
@@ -1244,3 +1247,11 @@ tasks.register('community154Interop', JavaExec) {
mainClass = 'fr.koka.sanctuary.community.CommunityInterop'
args(providers.gradleProperty('communityInteropPhase').getOrElse('seed'))
}
tasks.register('serverMetrics161Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.community.ServerMetricsSmoke'
}
tasks.named('check') { dependsOn('serverMetrics161Smoke') }
@@ -141,6 +141,8 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.audience.shared");press(m.gui.screen(),"sanctuary.community.reward.none");press(m.gui.screen(),"sanctuary.community.reward.items");edit(m.gui.screen()).setValue("Un repas au village");press(m.gui.screen(),"sanctuary.community.save");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),400);
c.takeScreenshot("cards158-notice-details");
noticeSceneChecks(c);
world.getServer().runOnServer(server->{var inventory=server.getPlayerList().getPlayers().getFirst().getInventory();check(inventory.getItem(6).getCount()==17&&inventory.getItem(6).is(net.minecraft.world.item.Items.OAK_LOG)&&inventory.getItem(7).getCount()==3&&inventory.getItem(7).is(net.minecraft.world.item.Items.DIAMOND),"Descriptive chest preserves real inventory");});
c.runOnClient(m->{check(text(m.gui.screen(),"Un repas au village"),"Custom reward round trip");CommunityClient.open(new PauseScreen(true),"notice",noticeId,false);});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.follow")&&!CommunityClient.pending(),300);c.waitTicks(25);
@@ -182,6 +184,7 @@ public final class Community158ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().startsWith("Allow Commands")).findFirst().orElseThrow().onPress(enter());widgets(m.gui.screen()).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getString().equals("Apply Changes")).findFirst().orElseThrow().onPress(enter());});
c.waitFor(m->ProgressionClient.operator,150);c.runOnClient(m->{m.gui.setScreen(null);m.getConnection().sendCommand("sanctuary community admin");});
c.waitFor(m->m.gui.screen() instanceof CommunityScreen&&has(m.gui.screen(),"sanctuary.community.publish")&&!CommunityClient.pending(),200);
c.waitTicks(25); // Respect the server write cooldown after the preceding unfollow.
c.runOnClient(m->{press(m.gui.screen(),"sanctuary.community.publish");edit(m.gui.screen()).setValue("Message officiel");body(m.gui.screen()).setValue("Bienvenue !");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.back")&&!CommunityClient.pending(),250);
c.runOnClient(m->{check(!has(m.gui.screen(),"sanctuary.community.follow")&&!has(m.gui.screen(),"sanctuary.community.reply"),"Administrative bulletin has no quest or replies");CommunityClient.open(null,"bulletin","",false);});
@@ -194,19 +197,62 @@ public final class Community158ClientChecks implements FabricClientGameTest {
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 date=widgets(m.gui.screen()).filter(StringWidget.class::isInstance).map(StringWidget.class::cast).filter(w->w.getMessage().getString().contains(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");
var screen=m.gui.screen();
check(date.getX()==8&&date.getHeight()==38&&date.getY()==3,"Calendar framed at top left");
check(!has(screen,"sanctuary.progression.history"),"World navigation removed");
var discovery=pauseButton(screen,"sanctuary.progression.blocks");var progression=pauseButton(screen,"sanctuary.progression.progress");var inhabitant=pauseButton(screen,"sanctuary.progression.identity");
check(date.getWidth()==discovery.getWidth(),"Calendar matches navigation width");
check(official.getX()==date.getRight()+10&&official.getRight()==screen.width-8,"Bulletin spans map and community panels");
check(date.getMessage().getString().matches(".*\\d{2}:\\d{2}$"),"Clock included in calendar");
check(inhabitant.getY()-progression.getBottom()>progression.getY()-discovery.getBottom(),"Personal and social navigation groups separated");
var navigation=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=50&&b.getY()<pauseButton(screen,"menu.options").getY()).toList();
check(navigation.size()==5,"Five navigation destinations");
check(navigation.stream().allMatch(b->b.getBottom()<pauseButton(screen,"menu.options").getY()),"Navigation clears settings");
var resume=pauseButton(screen,"menu.returnToGame");
check(Math.abs(resume.getX()+resume.getWidth()/2-screen.width/2)<=1&&resume.getY()>screen.height-33,"Resume centered in footer");
check(pauseButton(screen,"options.worldOptions.button").getBottom()<screen.height-39,"World settings remain in body");
for(var child:screen.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Pause widget within viewport");
});
c.takeScreenshot("search159-pause-year-"+locale+"-"+scale);
c.takeScreenshot("pause164-calendar-"+locale+"-"+scale);
}
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("PAUSE164_LAYOUT_PASS");
System.out.println("SEARCH159_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
private static void noticeSceneChecks(ClientGameTestContext c) {
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
c.waitTicks(3);
c.runOnClient(m->{
var s=m.gui.screen();var scene=widgets(s).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();
check(button(s,"back").getBottom()<=scene.getY(),"Back/refresh fixed above scene");
if(s.width>=522) {
check(body(s).getX()>=scene.getRight(),"Conversation to the right of author scene");
check(button(s,"reply").getBottom()<=s.height-33,"Reply stays above footer");
} else check(body(s).getY()>scene.getBottom(),"Narrow GUI stacks conversation below scene");
body(s).setValue("Brouillon scène162");hover(m,0,0);
});
c.waitTicks(3);
var left=new java.util.concurrent.atomic.AtomicReference<Float>();
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();left.set(scene.yaw());hover(m,m.gui.screen().width,m.gui.screen().height);});
c.waitTicks(3);
c.runOnClient(m->{var scene=widgets(m.gui.screen()).filter(CommunityNoticeScene.class::isInstance).map(CommunityNoticeScene.class::cast).findFirst().orElseThrow();check(scene.yaw()>left.get()&&Math.abs(scene.pitch())<50,"Portrait tracks pointer within bounded angles");});
c.takeScreenshot("notice162-scene-"+locale+"-"+scale);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();check(body(m.gui.screen()).getValue().equals("Brouillon scène162"),"Draft survives GUI resize");body(m.gui.screen()).setValue("Réponse scène162");press(m.gui.screen(),"sanctuary.community.reply");});
c.waitFor(m->!CommunityClient.pending()&&widgets(m.gui.screen()).filter(CommunityBubble.class::isInstance).map(CommunityBubble.class::cast).anyMatch(w->w.getMessage().getString().equals("Réponse scène162")),300);
c.takeScreenshot("notice162-conversation");
System.out.println("NOTICE162_SCENE_PASS");
}
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 Button pauseButton(Screen screen,String key) {return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
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();}
}
@@ -54,14 +54,14 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{
var s=m.gui.screen();
check(s instanceof PauseAtlasAccess,"Pause atlas access");
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.history","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=40&&b.getY()<s.height-76).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
check(navigation.size()==6,"Six real navigation buttons");
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()==4,"Uniform 4-pixel navigation spacing");
for(String key:List.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.identity","menu.options","options.worldOptions.button","menu.returnToMenu","menu.returnToGame"))check(has(s,key),"Destination "+key);
var navigation=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getX()==8&&b.getY()>=50&&b.getY()<nativeButton(s,"menu.options").getY()).sorted(java.util.Comparator.comparingInt(Button::getY)).toList();
check(navigation.size()==5&&!has(s,"sanctuary.progression.history"),"Five real navigation buttons without World");
for(int i=1;i<navigation.size();i++)check(navigation.get(i).getY()-navigation.get(i-1).getBottom()>=(i==2?8:3),"Two spaced navigation groups");
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(options.getX()==8 && worldOptions.getX()==8 && quit.getX()==8 && worldOptions.getY()-options.getBottom()==4 && quit.getY()-worldOptions.getBottom()==4 && quit.getBottom()==s.height-39,"Options and quit stacked at bottom left");
var feed=areas(s).getFirst();
check(resume.getX()==feed.getX() && resume.getWidth()==feed.getWidth() && resume.getBottom()==s.height-8 && areas(s).getLast().getBottom()<resume.getY(),"Resume aligned below right community column");
check(Math.abs(resume.getX()+resume.getWidth()/2-s.width/2)<=1 && resume.getY()>s.height-33 && areas(s).getLast().getBottom()<resume.getY(),"Resume centered in native footer");
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);
@@ -0,0 +1,39 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.nio.file.*;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
public final class Session161ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
c.runOnClient(m->{
var marker=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
var saved=m.options.keyPlayerList.saveString();
try {
byte[] previous=Files.exists(marker)?Files.readAllBytes(marker):null;
try {
check(m.options.keyPlayerList.getDefaultKey().getValue()==InputConstants.KEY_U,"U is reset-to-default player list key");
Files.deleteIfExists(marker);
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
HotbarClient.migratePlayerListKey(m);
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U)),"Saved Tab migrates to U");
Files.delete(marker);
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8));
HotbarClient.migratePlayerListKey(m);
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F8)),"Custom binding retained");
m.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));
HotbarClient.migratePlayerListKey(m);
check(m.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB)),"Migration happens once");
System.out.println("SESSION161_KEYS_PASS");
} finally {
if(previous==null)Files.deleteIfExists(marker);else Files.write(marker,previous);
m.options.keyPlayerList.setKey(InputConstants.getKey(saved));net.minecraft.client.KeyMapping.resetMapping();m.options.save();
}
}catch(Exception error){throw new AssertionError(error);}
});
new Community158ClientChecks().runTest(c);
}
private static void check(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
@Override
public void onInitialize() {
fr.koka.sanctuary.community.CommunityNetwork.register();
fr.koka.sanctuary.community.ServerMetrics.register();
fr.koka.sanctuary.building.ColoredBricks.register();
fr.koka.sanctuary.gems.Gems.register();
fr.koka.sanctuary.sky.VortexClouds.register();
@@ -18,8 +18,8 @@ final class CommunityCard extends AbstractButton implements AutoCloseable {
private final PlayerFaceWidget face;
private final boolean compact,article;
private CommunityPhotoWidget photo;
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xff80b2d2;};}
private static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff202d37;};}
static int color(String category) {return switch(category){case "event"->0xffba93dd;case "work"->0xffd4ac71;case "need"->0xff80c1a4;default->0xffead47e;};}
static int background(String category) {return switch(category){case "event"->0xff2e253a;case "work"->0xff322b20;case "need"->0xff20322c;default->0xff34301f;};}
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"))));
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.client;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.PlayerSkinWidget;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.world.entity.player.PlayerSkin;
import net.minecraft.world.item.component.ResolvableProfile;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** A GUI-only author portrait and persistent speech bubble; no world entity is created. */
final class CommunityNoticeScene extends AbstractWidget {
private final JsonObject entry;
private final List<FormattedCharSequence> heading,title,body;
private final Portrait portrait;
private final int portraitWidth,bubbleX;
CommunityNoticeScene(int width,JsonObject entry) {
super(0,0,width,160,Component.literal(str(entry,"title")).append("\n").append(str(entry,"body")));
this.entry=entry;
portraitWidth=Math.clamp(width/4,56,94);bubbleX=portraitWidth+10;
var font=Minecraft.getInstance().font;int wrap=Math.max(36,width-bubbleX-18);
heading=font.split(text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))),wrap);
title=font.split(Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD),wrap);
body=font.split(Component.literal(str(entry,"body")),wrap);
height=Math.max(154,28+(heading.size()+title.size()+body.size())*11);
portrait=new Portrait(portraitWidth,140,skin(entry));active=false;
}
private static Supplier<PlayerSkin> skin(JsonObject entry) {
var m=Minecraft.getInstance();var uuid=UUID.fromString(str(entry,"authorId"));
var fallback=m.playerSkinRenderCache().createLookup(ResolvableProfile.createUnresolved(uuid));
return ()->{
var connection=m.getConnection();var online=connection==null?null:connection.getPlayerInfo(uuid);
return online!=null?online.getSkin():fallback.get().playerSkin();
};
}
float yaw(){return portrait.yaw;}
float pitch(){return portrait.pitch;}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
int x=getX(),y=getY(),bx=x+bubbleX,accent=CommunityCard.color(str(entry,"category"));
g.fill(x,y,getRight(),getBottom(),0xA013171C);
portrait.setPosition(x,y+6);portrait.extractRenderState(g,mx,my,delta);
g.fill(bx,y,getRight(),getBottom(),CommunityCard.background(str(entry,"category")));
g.outline(bx,y,width-bubbleX,height,accent);
for(int step=0;step<6;step++)g.fill(bx-step-1,y+39+step,bx-step,y+50-step,accent);
int top=y+9;
for(var line:heading){g.text(Minecraft.getInstance().font,line,bx+9,top,accent,false);top+=11;}
top+=5;
for(var line:title){g.text(Minecraft.getInstance().font,line,bx+9,top,0xfffaf5e6,false);top+=11;}
top+=5;
for(var line:body){g.text(Minecraft.getInstance().font,line,bx+9,top,0xffeeeae1,false);top+=11;}
}
@Override protected void updateWidgetNarration(NarrationElementOutput output){defaultButtonNarrationText(output);}
private static final class Portrait extends PlayerSkinWidget {
private float pitch=-5,yaw=30;
Portrait(int width,int height,Supplier<PlayerSkin> skin){super(width,height,Minecraft.getInstance().getEntityModels(),skin);active=false;}
@Override protected void extractWidgetRenderState(GuiGraphicsExtractor g,int mx,int my,float delta) {
float nextYaw=(float)Math.toDegrees(Math.atan((mx-getX()-getWidth()/2f)/160f))*0.55f;
float nextPitch=-(float)Math.toDegrees(Math.atan((my-getY()-30)/180f))*0.4f;
// Native 26.3 skin widget applies 2.5 degrees per drag unit; use bounded absolute targets.
super.onDrag(null,(nextYaw-yaw)/2.5,(pitch-nextPitch)/2.5);
yaw=nextYaw;pitch=nextPitch;
super.extractWidgetRenderState(g,mx,my,delta);
}
@Override protected void onDrag(MouseButtonEvent event,double dx,double dy) { }
}
}
@@ -16,67 +16,101 @@ import static fr.koka.sanctuary.client.CommunityClient.*;
public final class CommunityPausePanels implements AutoCloseable {
private final Screen screen;
private final PauseCommunityFeed articles, notices;
private final StringWidget date, clock;
private final Calendar date;
private final PauseTextButton bulletin;
private final PauseAtlasWidget map;
private int ticks;
private boolean closed;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
this.screen=screen;
var font=Minecraft.getInstance().font;
int margin=8,gap=10,top=40,bottom=screen.height-38;
int margin=8,gap=10,top=50,bottom=screen.height-39;
int left=Math.min(132,Math.max(76,screen.width/5)),right=Math.min(244,Math.max(104,screen.width*31/100));
int centerX=margin+left+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
date=new StringWidget(margin,8,left,20,Component.empty(),font);
clock=new StringWidget(rightX,8,right,20,Component.empty(),font);
add.accept(date);add.accept(clock);
int headerSide=Math.max(84,screen.width/5);
bulletin=new PauseTextButton(headerSide,3,screen.width-2*headerSide,32,text("bulletin"),()->open(screen,"bulletin","",false));
int optionHeight=screen.height<280?16:20,navGap=screen.height<280?3:4,groupGap=screen.height<280?8:12;
int optionsTop=bottom-(3*optionHeight+8);
int navHeight=Math.min(20,Math.max(11,(optionsTop-top-8-4*navGap-groupGap)/5));
int step=navHeight+navGap;
add.accept(new Frame(screen.width,screen.height,left,top+2*step+(groupGap-navGap)/2));
date=new Calendar(margin,3,left);
add.accept(date);
bulletin=new PauseTextButton(centerX,3,screen.width-margin-centerX,38,text("bulletin"),()->open(screen,"bulletin",items("bulletins").isEmpty()?"":str(items("bulletins").get(0).getAsJsonObject(),"id"),false));
add.accept(bulletin);
String[] sections={"blocks","history","progress","identity"};
int optionsTop=screen.height-76;
int navHeight=Math.min(20,Math.max(16,(optionsTop-top-28)/6));
int step=navHeight+4;
String[] sections={"blocks","progress","identity"};
for(int i=0;i<sections.length;i++) {
String target=sections[i];
String target=sections[i];int y=top+i*step+(i>=2?groupGap:0);
add.accept(Button.builder(fr.koka.sanctuary.progression.ProgressionService.label(target),b->ProgressionClient.open(screen,target))
.bounds(margin,top+i*step,left,navHeight).build());
.bounds(margin,y,left,navHeight).build());
}
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());
add.accept(Button.builder(fr.koka.sanctuary.cycle.CycleService.label("faction"),b->CycleClient.open(screen,"faction")).bounds(margin,top+3*step+groupGap,left,navHeight).build());
add.accept(Button.builder(fr.koka.sanctuary.arena.Arenas.text("title"),b->ArenaClient.open(screen)).bounds(margin,top+4*step+groupGap,left,navHeight).build());
map=new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top));add.accept(map);
place(nativeButtons,add,"menu.returnToGame",rightX,screen.height-28,right);
int resumeWidth=Math.min(200,screen.width-24);
place(nativeButtons,add,"menu.returnToGame",(screen.width-resumeWidth)/2,screen.height-26,resumeWidth);
int feedHeight=(bottom-top-gap)/2;
articles=new PauseCommunityFeed(screen,add,rightX,top,right,feedHeight,"article");
notices=new PauseCommunityFeed(screen,add,rightX,top+feedHeight+gap,right,bottom-top-feedHeight-gap,"notice");
place(nativeButtons,add,"menu.options",margin,optionsTop,left);
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+24,left);
place(nativeButtons,add,"menu.options",margin,optionsTop,left,optionHeight);
place(nativeButtons,add,"options.worldOptions.button",margin,optionsTop+optionHeight+4,left,optionHeight);
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
button.setRectangle(left,20,margin,optionsTop+48);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
button.setRectangle(left,optionHeight,margin,optionsTop+2*(optionHeight+4));button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
}
updateHeader();
}
public static String key(Button button) { return button.getMessage().getContents() instanceof TranslatableContents t?t.getKey():""; }
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width) {
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,20,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
place(buttons,add,key,x,y,width,20);
}
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width,int height) {
buttons.stream().filter(b->key(b).equals(key)).findFirst().ifPresent(b->{b.setRectangle(width,height,x,y);b.setTooltip(Tooltip.create(b.getMessage()));add.accept(b);});
}
private static JsonArray items(String key) { return overview.has(key)?overview.getAsJsonArray(key):new JsonArray(); }
private void updateHeader() {
var locale=Locale.forLanguageTag(Minecraft.getInstance().getLanguageManager().getSelected().replace('_','-'));
var now=ZonedDateTime.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()));
date.update(now,locale);
var list=items("bulletins");
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title")+" · "+str(list.get(0).getAsJsonObject(),"body"));
Component message=list.isEmpty()?text("bulletin_empty"):Component.literal(str(list.get(0).getAsJsonObject(),"title"));
if(!overviewError.isEmpty())message=text(overviewError);
bulletin.setMessage(message);
bulletin.setTooltip(Tooltip.create(text("bulletin").append("\n").append(list.isEmpty()?message:Component.literal(str(list.get(0).getAsJsonObject(),"body")))));
date.setWidth(Minecraft.getInstance().font.width(date.getMessage()));
int left=Math.max(date.getRight()+8,screen.width/5),right=screen.width-Math.max(56,screen.width/5);
bulletin.setRectangle(Math.max(24,right-left),32,left,3);
int left=date.getRight()+10,right=screen.width-8;
bulletin.setRectangle(Math.max(24,right-left),38,left,3);
bulletin.active=available();
}
/** Shares Progression's native header/footer separator textures. */
private static final class Frame extends AbstractWidget {
private final int left,groupY;
Frame(int width,int height,int left,int groupY){super(0,0,width,height,Component.empty());this.left=left;this.groupY=groupY;active=false;}
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
var texture=Minecraft.getInstance().level==null?Screen.HEADER_SEPARATOR:Screen.INWORLD_HEADER_SEPARATOR;
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,44,0,0,width,2,32,2);
texture=Minecraft.getInstance().level==null?Screen.FOOTER_SEPARATOR:Screen.INWORLD_FOOTER_SEPARATOR;
g.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED,texture,0,height-33,0,0,width,2,32,2);
g.fill(12,groupY,left+4,groupY+1,0x665F6869);
}
@Override protected void updateWidgetNarration(net.minecraft.client.gui.narration.NarrationElementOutput out) { }
}
private static final class Calendar extends StringWidget {
private String day="",monthYear="",time="";
private final int calendarWidth;
Calendar(int x,int y,int width){super(x,y,width,38,Component.empty(),Minecraft.getInstance().font);calendarWidth=width;}
void update(ZonedDateTime now,Locale locale){
day=DateTimeFormatter.ofPattern("d",locale).format(now);monthYear=DateTimeFormatter.ofPattern("MMMM yyyy",locale).format(now);time=DateTimeFormatter.ofPattern("HH:mm",locale).format(now);
setMessage(Component.literal(DateTimeFormatter.ofPattern("d MMMM yyyy HH:mm",locale).format(now)));
setWidth(calendarWidth);
}
@Override public void extractWidgetRenderState(net.minecraft.client.gui.GuiGraphicsExtractor g,int mx,int my,float delta) {
var font=Minecraft.getInstance().font;int x=getX(),y=getY();
g.fill(x,y,getRight(),getBottom(),0xA0202529);g.outline(x,y,width,height,0xFF66716E);
g.pose().pushMatrix();g.pose().translate(x+6,y+4);g.pose().scale(1.8f,1.8f);
g.text(font,day,0,0,0xFFF0EEE6,false);g.pose().popMatrix();
g.text(font,time,getRight()-6-font.width(time),y+9,0xFFE0DED6,false);
// Keep the month and year together even at the smallest GUI width.
float scale=Math.min(1f,(width-12f)/Math.max(1,font.width(monthYear)));
g.pose().pushMatrix();g.pose().translate(x+6,y+25);g.pose().scale(scale,scale);
g.text(font,monthYear,0,0,0xFFE0DED6,false);g.pose().popMatrix();
}
}
public void tick() {
if(closed)return;
map.tick(); updateHeader();
@@ -55,6 +55,7 @@ public final class CommunityScreen extends BlocodexFrame {
@Override protected void init() {
searchBox=null;
closePhotos();initFrame("history",text(kind)); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
if(!composing && kind.equals("notice") && data.has("thread")) {noticeDetail(data.getAsJsonObject("thread"),w);return;}
var layout = LinearLayout.vertical().spacing(8);
if (!message.isEmpty()) line(layout,w,text(message));
if (!available()) line(layout,w,text("unavailable"));
@@ -100,23 +101,57 @@ public final class CommunityScreen extends BlocodexFrame {
if(kind.equals("notice"))taskDetails(layout,w,entry);
}
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
if (!kind.equals("bulletin")) {
line(layout,w,text("replies"));
var replies = thread.getAsJsonObject("replies");
for (var item : replies.getAsJsonArray("entries")) message(layout,w,item.getAsJsonObject());
pages(layout,w,replies);
if (str(entry,"status").equals("open")) {
useDraft("reply:"+id);
var replyDraft=draft;
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
box.setCharacterLimit(1000); box.setValue(replyDraft.body); box.setValueListener(value -> replyDraft.body=value);
button(layout,100,"reply",() -> request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result -> {
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
DRAFTS.values().removeIf(value -> value==replyDraft); message="saved"; needsLoad=true; stale=true;
}));
}
if (!kind.equals("bulletin")) { replies(layout,w,thread);replyComposer(layout,w,entry); }
}
private void replies(LinearLayout layout,int w,JsonObject thread) {
line(layout,w,text("replies"));
var replies=thread.getAsJsonObject("replies");
for(var item:replies.getAsJsonArray("entries"))message(layout,w,item.getAsJsonObject());
if(replies.getAsJsonArray("entries").isEmpty())line(layout,w,text("conversation_empty"));
pages(layout,w,replies);
}
private void replyComposer(LinearLayout layout,int w,JsonObject entry) {
if(!str(entry,"status").equals("open"))return;
useDraft("reply:"+id);var replyDraft=draft;
var box=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("reply_hint")).build(font,w,52,text("reply")));
box.setCharacterLimit(1000);box.setValue(replyDraft.body);box.setValueListener(value->replyDraft.body=value);
button(layout,Math.min(w,100),"reply",()->request(this,"create",replyDraft.operation,id,"reply","info","",replyDraft.body,0,0,result->{
if(result.has("error")){message=str(result,"error");rebuildWidgets();return;}
DRAFTS.values().removeIf(value->value==replyDraft);message="saved";needsLoad=true;stale=true;
}));
}
private void noticeDetail(JsonObject thread,int w) {
var entry=thread.getAsJsonObject("entry");
var toolbar=LinearLayout.horizontal().spacing(5);
button(toolbar,90,"back",()->{id="";page=0;data=new JsonObject();needsLoad=true;rebuildWidgets();});
button(toolbar,90,"refresh",()->needsLoad=true);
toolbar.setPosition(contentX+8,contentTop);toolbar.arrangeElements();toolbar.visitWidgets(this::addRenderableWidget);
int top=contentTop+28,availableHeight=contentBottom-top;
boolean columns=w>=470 && availableHeight>=180;
int leftWidth=columns?(w-20)*3/5:w;
var scene=LinearLayout.vertical().spacing(8);
if(!message.isEmpty())line(scene,leftWidth,text(message));
if(!available())line(scene,leftWidth,text("unavailable"));
scene.addChild(new CommunityNoticeScene(leftWidth,entry));
author(scene,leftWidth,entry,true);
if(expandedId.equals(id))controls(scene,leftWidth,entry);
taskDetails(scene,leftWidth,entry);
if(columns) {
scrollColumn(scene,leftWidth,contentX+8,top,availableHeight);
int rightWidth=w-leftWidth-20,rightX=contentX+8+leftWidth+20;
var conversation=LinearLayout.vertical().spacing(8);replies(conversation,rightWidth,thread);
int composeHeight=str(entry,"status").equals("open")?88:0;
scrollColumn(conversation,rightWidth,rightX,top,availableHeight-composeHeight);
if(composeHeight>0){var compose=LinearLayout.vertical().spacing(8);replyComposer(compose,rightWidth,entry);compose.setPosition(rightX,contentBottom-80);compose.arrangeElements();compose.visitWidgets(this::addRenderableWidget);}
} else {
replies(scene,w,thread);replyComposer(scene,w,entry);
scrollColumn(scene,w,contentX+8,top,availableHeight);
}
}
private void scrollColumn(LinearLayout layout,int w,int x,int y,int h) {
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,h),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setMinWidth(w);scroll.setPosition(x,y);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private boolean own(JsonObject entry) { return minecraft.player!=null && str(entry,"authorId").equals(minecraft.player.getStringUUID()); }
private void articleAuthor(LinearLayout layout,int w,JsonObject entry) {
var row=layout.addChild(LinearLayout.horizontal().spacing(6));
@@ -209,6 +244,8 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void taskDetails(LinearLayout layout,int w,JsonObject entry) {
var task=readTask(entry);
if(!task.equals(fr.koka.sanctuary.community.CommunityTask.EMPTY)) {
line(layout,w,text("request_details"));
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());
@@ -220,10 +257,13 @@ public final class CommunityScreen extends BlocodexFrame {
if(task.reward().equals("items"))CommunityMaterialsScreen.showItems(layout,w,task.rewards());
if(task.reward().equals("custom"))line(layout,w,Component.literal(task.customReward()));
line(layout,w,text("exchange_help"));
}
int count=data.has("members")?data.getAsJsonArray("members").size():0;
line(layout,w,text("followers",count));
if(data.has("members"))for(var member:data.getAsJsonArray("members"))line(layout,w,Component.literal("• "+str(member.getAsJsonObject(),"playerName")));
boolean subscribed=flag(data,"subscribed");
if(str(entry,"status").equals("open"))line(layout,w,text(subscribed?"follow_map_active":"follow_map_help"));
if(!subscribed && str(entry,"status").equals("open") && task.audience().equals("single") && count>0)line(layout,w,text("notice_full_help"));
var follow=layout.addChild(Button.builder(text(subscribed?"unfollow":"follow"),b->request(this,subscribed?"unfollow":"follow",id,"","notice","info","","",0,0,result->{
message=result.has("error")?str(result,"error"):"saved";needsLoad=true;stale=true;
if(!result.has("error")&&!subscribed)NotificationClient.communitySubscribed(Component.literal(str(entry,"title")));
@@ -21,7 +21,20 @@ public final class HotbarClient {
private static boolean held;
private static boolean worldHeld;
private HotbarClient() {}
static void migratePlayerListKey(net.minecraft.client.Minecraft client) {
var marker=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-player-list-key-v1.txt");
if(java.nio.file.Files.exists(marker))return;
try {
if(client.options.keyPlayerList.matches(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB))) {
client.options.keyPlayerList.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_U));
KeyMapping.resetMapping();client.options.save();
}
java.nio.file.Files.createDirectories(marker.getParent());
java.nio.file.Files.writeString(marker,"beta.161\n",java.nio.file.StandardOpenOption.CREATE_NEW);
} catch(java.io.IOException error) {fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Cannot record player list key migration",error);}
}
public static void register(KeyMapping.Category category) {
net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents.CLIENT_STARTED.register(HotbarClient::migratePlayerListKey);
key=KeyMappingHelper.registerKeyMapping(new KeyMapping("screen.sanctuary.inventory.rotate_key",InputConstants.Type.KEYBOARD,InputConstants.KEY_TAB,category));
ClientConfigurationNetworking.registerGlobalReceiver(HotbarPayloads.Capability.TYPE,(p,c) -> {});
ClientPlayNetworking.registerGlobalReceiver(HotbarPayloads.State.TYPE,(p,c) -> c.client().execute(() -> {
@@ -17,7 +17,7 @@ final class PauseTextButton extends AbstractButton {
var font=Minecraft.getInstance().font;
if(height>24) {
var lines=font.split(getMessage(),width-10);int count=Math.min(2,lines.size());
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+(width-font.width(lines.get(i)))/2,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
for(int i=0;i<count;i++)g.text(font,lines.get(i),getX()+5,getY()+(height-count*10)/2+i*10,active?0xFFF0EEE6:0xFF969696);
return;
}
g.text(font,font.plainSubstrByWidth(getMessage().getString(),width-10),getX()+5,getY()+(height-9)/2,active?0xFFF0EEE6:0xFF969696);
@@ -0,0 +1,79 @@
package fr.koka.sanctuary.community;
import fr.koka.sanctuary.SanctuaryMod;
import java.nio.charset.StandardCharsets;
import java.sql.DriverManager;
import java.util.*;
import java.util.concurrent.*;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.level.storage.LevelResource;
/** Server-thread observations, bounded asynchronous persistence; no web requests touch the game thread. */
public final class ServerMetrics {
private static final Map<MinecraftServer,Session> SESSIONS=new HashMap<>();
private ServerMetrics() { }
public static void register() {
ServerLifecycleEvents.SERVER_STARTED.register(server -> {if(!server.isSingleplayer()) {var s=new Session(server);SESSIONS.put(server,s);s.capture(true);}});
ServerTickEvents.END_SERVER_TICK.register(server -> {var s=SESSIONS.get(server);if(s!=null && System.nanoTime()-s.lastCapture>=30_000_000_000L)s.capture(true);});
ServerPlayConnectionEvents.DISCONNECT.register((handler,server)->{var s=SESSIONS.get(server);if(s!=null)s.observe(handler.player);});
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {var s=SESSIONS.remove(server);if(s!=null)s.stop();});
}
private static boolean actor(ServerPlayer player) {
String name=player.getGameProfile().name();
return name.startsWith("Lab_") && player.getUUID().equals(UUID.nameUUIDFromBytes(("SanctuaryDuo:"+name).getBytes(StandardCharsets.UTF_8)));
}
private static final class Session {
final MinecraftServer server;
final ThreadPoolExecutor worker=new ThreadPoolExecutor(1,1,0L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(2),r->{var t=new Thread(r,"sanctuary-metrics");t.setDaemon(true);return t;});
final Map<String,Long> latest=new HashMap<>();
final Set<String> actors=new HashSet<>();
final long started=System.nanoTime()/1_000_000;
ServerMetricsLedger ledger;
CommunityConfig config;
boolean broken;
long lastCapture,lastSqlWarning;
Session(MinecraftServer server){this.server=server;}
void observe(ServerPlayer player) {
if(actor(player))actors.add(player.getStringUUID());
else latest.put(player.getStringUUID(),(long)player.getStats().getValue(Stats.CUSTOM.get(Stats.DEATHS)));
}
void capture(boolean running) {
lastCapture=System.nanoTime();
int online=0;
for(var player:server.getPlayerList().getPlayers()) {observe(player);if(!actor(player))online++;}
var values=Map.copyOf(latest);var excluded=Set.copyOf(actors);
long monotonic=lastCapture/1_000_000,wall=System.currentTimeMillis();int count=running?online:0;
try {worker.execute(()->persist(monotonic,wall,values,excluded,count,running));}
catch(RejectedExecutionException error){SanctuaryMod.LOGGER.warn("Server metrics worker busy; next sample will include elapsed time");}
}
void persist(long monotonic,long wall,Map<String,Long> values,Set<String> excluded,int online,boolean running) {
if(broken)return;
ServerMetricsLedger.Snapshot snapshot;
try {
if(ledger==null)ledger=new ServerMetricsLedger(server.getWorldPath(LevelResource.ROOT),started);
snapshot=ledger.sample(monotonic,wall,values,excluded,online,running);
} catch(Exception error) {broken=true;SanctuaryMod.LOGGER.error("Server metrics stopped; existing local data preserved ({})",error.getClass().getSimpleName());return;}
try {
if(config==null)config=CommunityConfig.load(FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
if(!config.mode().equals("database"))return;
String password=System.getenv(config.passwordEnv());if(password==null)throw new IllegalStateException("Missing metrics database credential");
var props=new Properties();props.setProperty("user",config.user());props.setProperty("password",password);props.setProperty("connectTimeout","2000");props.setProperty("socketTimeout","3000");
try(var c=DriverManager.getConnection(config.jdbcUrl(),props);var stmt=c.prepareStatement("INSERT INTO sanctuary_server_metrics (server_id,uptime_millis,deaths,online_players,updated_at,running) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE uptime_millis=VALUES(uptime_millis),deaths=VALUES(deaths),online_players=VALUES(online_players),updated_at=VALUES(updated_at),running=VALUES(running)")) {
stmt.setQueryTimeout(3);stmt.setString(1,config.serverId());stmt.setLong(2,snapshot.uptimeMillis());stmt.setLong(3,snapshot.deaths());stmt.setInt(4,snapshot.onlinePlayers());stmt.setLong(5,snapshot.updatedAt());stmt.setBoolean(6,snapshot.running());stmt.executeUpdate();
}
} catch(Exception error) {
if(lastSqlWarning==0 || wall-lastSqlWarning>=300_000){lastSqlWarning=wall;SanctuaryMod.LOGGER.warn("Server metrics publication unavailable; local measurements retained ({})",error.getClass().getSimpleName());}
}
}
void stop() {
capture(false);worker.shutdown();
try {if(!worker.awaitTermination(8,TimeUnit.SECONDS))worker.shutdownNow();}
catch(InterruptedException error){worker.shutdownNow();Thread.currentThread().interrupt();}
}
}
}
@@ -0,0 +1,56 @@
package fr.koka.sanctuary.community;
import com.google.gson.*;
import fr.koka.sanctuary.progression.AtomicJson;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
/** Additive ledger: elapsed process time, vanilla deaths, and explicitly excluded lab actors. */
public final class ServerMetricsLedger {
private static final Gson JSON = new Gson();
private final Path path;
private byte[] previous;
private long uptimeMillis;
private final Map<String,Long> deaths = new HashMap<>();
private final Set<String> excluded = new HashSet<>();
private long sampledAt;
public record Snapshot(long uptimeMillis, long deaths, int onlinePlayers, long updatedAt, boolean running) { }
public ServerMetricsLedger(Path root, long monotonicMillis) throws Exception {
path=root.resolve("data/sanctuary-server-metrics.json");
sampledAt=monotonicMillis;
previous=AtomicJson.read(path,4*1024*1024);
if(previous!=null) {
var data=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8));
AtomicJson.fields(data,"version","uptimeMillis","deaths","excluded");
var o=data.getAsJsonObject();
if(o.get("version").getAsInt()!=1 || (uptimeMillis=o.get("uptimeMillis").getAsLong())<0)throw new IllegalArgumentException("Metrics version or uptime");
for(var e:o.getAsJsonObject("deaths").entrySet()) {Community.uuid(e.getKey());long value=e.getValue().getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");deaths.put(e.getKey(),value);}
for(var e:o.getAsJsonArray("excluded"))excluded.add(Community.uuid(e.getAsString()));
}
var stats=root.resolve("stats");
if(Files.isDirectory(stats))try(var files=Files.list(stats)) {
for(var file:files.filter(f->f.getFileName().toString().matches("[0-9a-f-]{36}\\.json")).toList()) {
String id=Community.uuid(file.getFileName().toString().substring(0,36));
byte[] bytes=AtomicJson.read(file,8*1024*1024);
var o=AtomicJson.parse(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();
var all=o.getAsJsonObject("stats");var custom=all==null?null:all.getAsJsonObject("minecraft:custom");
if(custom!=null && custom.has("minecraft:deaths")) {
long value=custom.get("minecraft:deaths").getAsLong();if(value<0)throw new IllegalArgumentException("Deaths");
deaths.merge(id,value,Math::max);
}
}
}
}
public Snapshot sample(long monotonicMillis,long wallMillis,Map<String,Long> currentDeaths,Set<String> actors,int online,boolean running) throws Exception {
if(monotonicMillis<sampledAt || wallMillis<0 || online<0)throw new IllegalArgumentException("Metrics clock");
uptimeMillis=Math.addExact(uptimeMillis,monotonicMillis-sampledAt);sampledAt=monotonicMillis;
excluded.addAll(actors);
for(var e:currentDeaths.entrySet()) {Community.uuid(e.getKey());if(e.getValue()<0)throw new IllegalArgumentException("Deaths");deaths.merge(e.getKey(),e.getValue(),Math::max);}
excluded.forEach(deaths::remove);
var o=new JsonObject();o.addProperty("version",1);o.addProperty("uptimeMillis",uptimeMillis);o.add("deaths",JSON.toJsonTree(deaths));o.add("excluded",JSON.toJsonTree(excluded));
previous=AtomicJson.write(path,previous,JSON.toJson(o),4*1024*1024);
long total=0;for(long value:deaths.values())total=Math.addExact(total,value);
return new Snapshot(uptimeMillis,total,online,wallMillis,running);
}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.mixin.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.Options;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArgs;
import org.spongepowered.asm.mixin.injection.invoke.arg.Args;
@Mixin(Options.class)
public abstract class PlayerListKeyMixin {
@ModifyArgs(method = "<init>", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyMapping;<init>(Ljava/lang/String;ILnet/minecraft/client/KeyMapping$Category;)V"))
private void sanctuary$playerListDefault(Args args) {
if ("key.playerlist".equals(args.get(0))) args.set(1, InputConstants.KEY_U);
}
}
@@ -2439,5 +2439,10 @@
"sanctuary.community.task_expired": "Request expired. Please publish again.",
"sanctuary.community.own_notice": "You cannot follow your own request.",
"sanctuary.community.follow_limit": "You already follow 16 requests.",
"sanctuary.community.notice_full": "This request has no available places."
"sanctuary.community.notice_full": "This request has no available places.",
"sanctuary.community.follow_map_active": "Following: a map marker appears when a location is provided.",
"sanctuary.community.follow_map_help": "Replying does not subscribe you. Follow this request to display its map marker.",
"sanctuary.community.notice_full_help": "The single place is taken. You can reply, but cannot follow this request.",
"sanctuary.community.conversation_empty": "No replies yet.",
"sanctuary.community.request_details": "The request"
}
@@ -2439,5 +2439,10 @@
"sanctuary.community.task_expired": "La demande a expiré. Réessayez de publier.",
"sanctuary.community.own_notice": "Vous ne pouvez pas suivre votre propre demande.",
"sanctuary.community.follow_limit": "Vous suivez déjà 16 demandes.",
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible."
"sanctuary.community.notice_full": "Cette demande n’a plus de place disponible.",
"sanctuary.community.follow_map_active": "Demande suivie : son repère apparaît sur la carte si un lieu est indiqué.",
"sanctuary.community.follow_map_help": "Répondre ne vous abonne pas. Suivez la demande pour afficher son repère sur la carte.",
"sanctuary.community.notice_full_help": "La place unique est occupée. Vous pouvez répondre, mais pas suivre cette demande.",
"sanctuary.community.conversation_empty": "Aucune réponse pour le moment.",
"sanctuary.community.request_details": "La demande"
}
@@ -289,7 +289,8 @@
"client.NaturalWaterFogColorMixin",
"client.NaturalWaterSectionCopyAccess",
"client.PauseAtlasServerMixin",
"client.CommunityPauseMixin"
"client.CommunityPauseMixin",
"client.PlayerListKeyMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,28 @@
package fr.koka.sanctuary.community;
import java.nio.file.*;
import java.util.*;
public final class ServerMetricsSmoke {
public static void main(String[] args) throws Exception {
Path root=Files.createTempDirectory("sanctuary-metrics-test");
String player="11111111-1111-4111-8111-111111111111",bot="22222222-2222-4222-8222-222222222222";
Files.createDirectories(root.resolve("stats"));
Files.writeString(root.resolve("stats/"+player+".json"),"{\"stats\":{\"minecraft:custom\":{\"minecraft:deaths\":12}}}");
var ledger=new ServerMetricsLedger(root,100);
var first=ledger.sample(3100,10000,Map.of(player,13L,bot,9L),Set.of(bot),1,true);
check(first.uptimeMillis()==3000 && first.deaths()==13 && first.onlinePlayers()==1,"imports offline deaths, counts current deaths, excludes actors");
ledger.sample(4100,11000,Map.of(),Set.of(),0,false);
var restarted=new ServerMetricsLedger(root,900000);
var next=restarted.sample(901000,9000000,Map.of(player,13L),Set.of(),1,true);
check(next.uptimeMillis()==5000 && next.deaths()==13,"downtime excluded and deaths not counted twice");
var death=restarted.sample(902000,9001000,Map.of(player,14L),Set.of(),1,true);
check(death.deaths()==14,"new death increments cumulative total");
Path file=root.resolve("data/sanctuary-server-metrics.json");Files.writeString(file,"corrupt");
try{restarted.sample(903000,9002000,Map.of(),Set.of(),0,false);throw new AssertionError("overwrite");}catch(java.io.IOException expected){}
check(Files.readString(file).equals("corrupt"),"external edit preserved");
try{new ServerMetricsLedger(root,0);throw new AssertionError("reset");}catch(IllegalArgumentException expected){}
System.out.println("SERVER_METRICS161_PASS");
}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.160"
version = "beta.164"
pack-format = "packwiz:1.1.0"
[index]