Compare commits

..
39 changed files with 1067 additions and 74 deletions
+25
View File
@@ -6,6 +6,31 @@ Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.237 — Aptitudes acquises définitivement
[Aptitudes par thème et achats permanents](docs/aptitudes-beta237.md) : seuls
World Map et Mob Names sont activables/désactivables. Les dix autres aptitudes
restent utilisables après achat ; Enchantement et Alchimie coûtent chacun
quatre niveaux et débloquent l'accès personnel aux postes natifs. Icônes
fournies intégrées, libellés FR/EN. Les anciens choix de désactivation de ces
achats sont effacés sans toucher aux achats ni à l'XP. Disposition Pause
beta.235 intégrée, adaptée au choix de masquer la carte. `check build assemblePack`,
37 GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.237](build/Sanctuary-beta.237.mrpack) vérifié ; aucune
publication ni mise à jour personnelle.
## beta.235 — Essai réversible du menu Pause
[Disposition adaptative](docs/pause-columns-beta235.md) : avant Atlas, Aventure
à gauche et Communauté à droite, avec Partie / Game juste dessous. Avec Atlas,
une colonne étroite à gauche, Communauté → Aventure → Game, carte au centre.
Bloc centré verticalement, libellés de navigation entiers et ordre clavier
adapté. Parcours Pause et Story Vulkan FR/EN GUI 2/3/4, `check build assemblePack`
et quatre GameTests ciblés réussis.
Le commit de disposition `a7bdfe6` peut être annulé indépendamment de la
livraison. [MRpack local beta.235](build/Sanctuary-beta.235.mrpack) vérifié,
sans publication ni mise à jour personnelle. Le rendu reste à apprécier.
## beta.234 — Story, recherche et filtres
[Premier menu Story](docs/story-beta234.md), sous Progression dans Aventure :
+140
View File
@@ -0,0 +1,140 @@
# APT-236 — aptitudes par thème et activation personnelle
La règle de désactivation décrite ici correspond à la livraison beta.236.
[Beta.237](aptitudes-beta237.md) la remplace : seuls World Map et Mob Names
restent désactivables, les autres aptitudes sont acquises définitivement.
Le chantier Pause beta.235 est intégré dans cette nouvelle livraison.
Branche `codex/progression-aptitudes`, base beta.234 (`f9c2ce8`). Le travail
parallèle du menu Pause occupe beta.235 ; ce lot réserve beta.236.
## Contrat avant implémentation
Progression regroupe les aptitudes en cinq thèmes : Survie (Swimming, Rest,
Food Knowledge), Inventaire et fabrication (Inventory Sorting, Catalogue),
Familiers (Active Bond, Familiar Study), Exploration (World Map, Mob Names,
Zoom), Arts et métiers (Enchantement, Alchimie). Le minage et la forgerie
restent hors de ce ticket.
Enchantement et Alchimie coûtent chacun 4 niveaux, comme les aptitudes actuelles.
L'usage personnel de la table d'enchantement et de l'alambic est verrouillé au
départ dans les mondes avec progression Sanctuary. Les menus natifs sont
refusés côté serveur tant que l'aptitude correspondante n'est pas acquise et
active, y compris via les blocs fonctionnels portés sur la tête. Les enclumes,
la table de forge, les équipements enchantés et les potions déjà obtenues
restent inchangés. Les alambics partagés et leur automatisation continuent
leur fonctionnement autonome : ce lot contrôle l'accès personnel au poste,
sans attribuer de propriétaire à un bloc ni arrêter une préparation lancée.
Les personnages existants avec progression Sanctuary doivent aussi acquérir
ces deux nouvelles aptitudes ; aucune acquisition gratuite n'est ajoutée.
Une aptitude achetée peut être activée ou désactivée sans coût et sans perdre
son achat. Food Knowledge reste permanent après apprentissage. Les compétences,
les prix et les découvertes ne changent pas. Les effets de gameplay sont
contrôlés côté serveur ; les aides d'interface utilisent le choix serveur.
Active Bond contrôle l'usage des pouvoirs de travail, pas les techniques de
combat, les pouvoirs passifs ou l'équipement du familier. Une action déjà
lancée se termine normalement ; les nouvelles activations sont refusées.
## Persistance et compatibilité
L'attachement additif `sanctuary:aptitude_preferences` conserve un entier de
0 à 2047 : un bit par aptitude désactivée, dans l'ordre immuable names, atlas,
sorting, swimming, resting, active_bond, familiar_study, catalogue, zoom,
enchanting, alchemy.
L'absence de cet attachement équivaut à zéro : toutes les aptitudes acquises
gardent leur comportement actuel. Il est copié à la mort et conservé au New
Game+. Deux attachements booléens additifs `sanctuary:enchanting_learned` et
`sanctuary:alchemy_learned` conservent leurs nouveaux achats ; leur absence
équivaut à false et ils sont copiés à la mort et conservés au New Game+.
Aucun schéma ou contenu des achats existants n'est réécrit. Ce contrat
est limité à cet attachement ; aucun monde personnel n'est ouvert ou modifié.
Une requête transmet la cible, le choix et le masque attendu ; le serveur
vérifie l'achat, la session, l'état du joueur et le masque courant. Un choix
inconnu, Food Knowledge, une aptitude non acquise ou une requête périmée est
refusé sans XP dépensée. Un état distinct est renvoyé avant le snapshot de
progression. La capacité réseau `aptitude_preferences_v1` exige les versions
compatibles sur client et serveur sans changer les paquets existants.
Désactiver Swimming interrompt le sprint aquatique ; désactiver Rest arrête
la récupération. World Map masque son entrée et refuse les nouvelles lectures
personnelles ; les relevés continuent d'être conservés et l'outil opérateur
reste disponible. Catalogue masque l'aide personnelle JEI et la consultation
complète de Discovery, sans toucher aux recettes ni aux découvertes.
Un ancien mod ignore ces trois nouveaux attachements et retrouve son ancien
accès aux postes. Il peut perdre les attachements inconnus en sauvegardant :
conserver une sauvegarde avant un retour arrière. Le schéma 9 de progression
et les preuves des aptitudes antérieures restent identiques.
## Vérifications
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=aptitudes236,progression,movement,sorting,recipes,foodknowledge \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryAptitudes236ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Première passe serveur : `check build assemblePack` réussi en 3 min 13 s,
**33 GameTests requis réussis**, dont cinq scénarios APT-236. Contrôles purs
du dépôt réussis. Le banc Atlas existant évite le conflit de génération du
chemin par défaut déjà documenté en beta.232. Monde de développement neuf.
Log : `build/aptitudes236-check-build-pack.log`.
- Onze choix indépendants, refus des requêtes inconnues, non acquises,
périmées et de Food Knowledge ; conservation exacte de l'XP et des preuves
d'achat. Achat à 3 niveaux refusé, à 4 accepté une seule fois.
- Aller-retour des nouveaux attachements dans le NBT joueur natif, copie à
la mort, conservation lors de la remise à zéro des compétences du cycle.
- Swimming arrête le sprint aquatique et interdit les flags de mouvement
directs ; Rest arrête la récupération ; tri falsifié et lectures Atlas
personnelles refusés. Réactivation du tri contrôlée avec la même requête.
Active Bond refuse une nouvelle activation avant toute planification.
- Table d'enchantement native refusée avant acquisition, ouverte ensuite ;
**une épée diamant est réellement enchantée**. Désactiver ferme le menu,
refuse sa réutilisation et son bouton d'enchantement sans dépense d'XP.
Alambic natif refusé puis ouvert, fermeture et invalidation à la désactivation,
ingrédients partagés conservés et réouverture après réactivation.
Parcours client final réussi en **52 s**, Minecraft 26.3, Vulkan confirmé par
MoltenVK 1.4.2 / Apple M1. Log : `build/aptitudes236-client-vulkan.log`, marqueur
`APTITUDES236_PASS` ; douze captures dans `build/aptitudes236-screenshots/`.
- FR/EN, GUI 2/3/4, fenêtre 1280 × 960 : cinq thèmes dans l'ordre, les douze
aptitudes présentes, Food Knowledge permanent et sans interrupteur.
- Achat des deux arts depuis les véritables boutons de Progression : huit
niveaux au total. **66 paires désactivation/réactivation** via les widgets
et le réseau, état serveur contrôlé, XP et progression finale identiques.
- Libellés d'état entièrement contenus dans les boutons ; défilement et
position conservée au redimensionnement 1344 × 1008 ; Terminé revient à
Pause. Une première passe a trouvé le libellé permanent trop large en GUI 4 :
retour à la ligne ajouté. Les interrupteurs affichent un état court et
expliquent leur action gratuite dans l'infobulle.
Les essais concernent les sauvegardes de développement. Pas de validation
Windows ou multijoueur distant. L'automatisation des alambics et les pouvoirs
déjà lancés restent les limites explicites du contrat ; la forgerie attend
son prochain ticket.
## Livraison locale
Build final `check build assemblePack` réussi en **2 min 24 s**, 136 tâches,
33/33 GameTests requis et contrôles purs réussis après les derniers réglages
d’interface. Log : `build/aptitudes236-check-build-pack-final.log`.
Versions du mod, du pack et du manifeste alignées sur **beta.236**.
[MRpack local](../build/Sanctuary-beta.236.mrpack) vérifié : 12797046 octets,
intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5, un seul JAR Sanctuary
identique au build final, sans classe de test ni monde ni module de laboratoire.
Les deux PNG fournis sont identiques octet pour octet aux originaux dans le JAR.
Reçu : `build/aptitudes236-artifact.json`.
- SHA-256 JAR : `21a4411ebed223a1d9f9d204dc9d7300af0d5b946416e39bb138f5c575bf5847`.
- SHA-256 MRpack : `9d3eca559805d451e8c653cca1d59186ae46e3c6964147b9fc072b9da832f921`.
La branche reste indépendante du chantier Pause beta.235 ; leur intégration
reste à effectuer. Aucune publication distante, mise à jour du canal packwiz
ou synchronisation d’instance personnelle.
+99
View File
@@ -0,0 +1,99 @@
# APT-237 — achats permanents et intégration du menu Pause
Branche `codex/progression-aptitudes`. Intégration des commits beta.235
`a7bdfe6` et `00d175c`, avec les aptitudes beta.236 `1c8e643`.
## Contrat avant implémentation
Seuls World Map et Mob Names disposent d'un interrupteur gratuit après achat.
Swimming, Rest, Inventory Sorting, Catalogue, Active Bond, Familiar Study,
Zoom, Enchantement, Alchimie et Food Knowledge restent acquis et utilisables
sans désactivation. Les prix et les conditions d'achat restent identiques.
Enchantement et Alchimie exigent toujours leur achat avant l'accès personnel
au poste natif. Les achats, l'XP et les preuves existantes sont conservés.
Le menu Pause reprend la disposition beta.235 : deux colonnes Aventure et
Communauté avant World Map, une colonne à gauche avec la carte. Le choix
personnel de masquer World Map suit aussi cette disposition.
## Contrat de migration des préférences beta.236
L'identifiant `sanctuary:aptitude_preferences`, son codec entier 0–2047 et
l'ordre historique des onze bits restent stables. Les bits 0 (Mob Names) et
1 (World Map) restent effectifs. Les bits 2 à 10 deviennent inertes et sont
retirés de cet attachement lors de sa prochaine synchronisation serveur.
Ainsi un ancien masque 2047 devient 3 : les deux aides restent masquées et
les neuf autres aptitudes achetées retrouvent automatiquement leur usage.
Le chargement accepte encore les anciennes valeurs ; les lectures et effets
ignorent immédiatement leurs bits désormais inertes. Aucune preuve d'achat,
aucun format de progression, aucun monde ou terrain n'est réécrit.
Les requêtes de désactivation des aptitudes permanentes sont refusées même
si elles proviennent d'un ancien client. Les paquets v1 restent inchangés ;
une capacité additive `sanctuary:aptitude_permanent_v1` impose un client avec
cette règle côté serveur. Les nouvelles interfaces sont traduites FR/EN.
Le retour à une ancienne version nécessite comme auparavant une sauvegarde,
car cette version ne peut restaurer les anciennes désactivations effacées.
## Vérification et livraison
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=aptitudes236,menus,progression,movement,sorting,recipes,foodknowledge \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryAptitudes236ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Les suites APT-236 sont actualisées pour vérifier la nouvelle règle et la
migration ; leurs noms Gradle restent stables. `check build assemblePack`
réussi en **2 min 34 s**, **37 GameTests requis réussis**, dont six scénarios
aptitudes. Contrôles purs du dépôt réussis. Le banc Atlas évite le chemin de
génération par défaut déjà documenté en beta.232. Log :
`build/aptitudes237-check-build-pack.log`.
- Les deux choix personnels sont gratuits ; refus des requêtes périmées,
inconnues, non acquises et des désactivations d'aptitudes permanentes.
Prix des nouveaux achats vérifié à trois/quatre niveaux, achat unique.
- Ancien masque 2047 rechargé dans le NBT natif, interprété en 3 puis
normalisé sans changement d'XP ni de preuve d'achat. Conservation des
choix et des achats à la mort et au New Game+.
- Avec d'anciens bits désactivés : sprint aquatique, Rest et tri restent
utilisables ; Active Bond suit son contrôle normal de familier équipé.
World Map conserve son contrôle d'accès et sa réactivation gratuite.
- Enchantement et Alchimie refusés avant achat, puis disponibles lors des
visites suivantes. Une épée diamant est réellement enchantée. Une requête
de désactivation refusée ne ferme ni n'invalide les menus acquis ; ingrédients
partagés de l'alambic conservés. Accès vanilla hors progression conservé.
Parcours client réussi en **32 s**, Minecraft 26.3, Vulkan / MoltenVK 1.4.2
sur Apple M1. FR/EN, GUI 2/3/4, fenêtre 1280 × 960 : cinq thèmes, douze
aptitudes, dix achats permanents, **12 paires désactivation/réactivation**
réelles des deux aides. Deux achats des arts via les boutons natifs de
Progression, huit niveaux au total. Libellés contenus dans leurs boutons,
XP et preuves conservées, défilement conservé au redimensionnement et retour
Terminé vers Pause.
La même suite réutilise les assertions Pause du collègue : disposition,
ordre clavier, marges, titres et libellés de navigation vérifiés après achat
de World Map, avec son choix personnel désactivé puis activé. Les deux
colonnes et la colonne près de la carte sont conservées. Les captures de
Pause portent sur la disposition ; les relevés et fils commencent à charger.
Log `build/aptitudes237-client-vulkan.log`, marqueur `APTITUDES237_PASS` ;
**24 captures** dans `build/aptitudes237-screenshots/`. Inspection des captures
Arts FR GUI 3, Pause sans carte FR GUI 4 et Pause avec carte EN GUI 3.
Versions alignées sur **beta.237**. [MRpack local](../build/Sanctuary-beta.237.mrpack)
vérifié : 12797901 octets, intégrité ZIP, Minecraft 26.3 / Fabric Loader 0.19.5,
un seul JAR Sanctuary identique au build final, sans tests ni monde ni module
de laboratoire. Les deux icônes fournies restent identiques octet pour octet
aux originaux. Reçu `build/aptitudes237-artifact.json`.
- SHA-256 JAR : `7ac741c6a15b8ab05a909cf0132c9d605b863723c0742a92d8b60c741fd760a6`.
- SHA-256 MRpack : `7635e9e7a425cbd7a7f6d020f9bb2bcced8cc1287967093346295e4b4b2250e9`.
Les anciens artefacts beta.236 sont conservés. Tests dans des mondes de
développement neufs ; Windows et multijoueur distant non testés. Les
alambics autonomes et pouvoirs déjà lancés gardent le contrat beta.236.
La forgerie, les enclumes et la table de forge attendent leur prochain ticket.
Aucune publication, avancée du canal ni synchronisation d'installation personnelle.
+19
View File
@@ -1,5 +1,24 @@
# Backlog Sanctuary
## APT-237 — achats permanents et intégration Pause — livré localement
Branche `codex/progression-aptitudes`, travaux beta.235 et beta.236 réunis.
Cinq thèmes, douze aptitudes ; seuls World Map et Mob Names ont un interrupteur.
Enchantement et Alchimie restent à acheter avant l'accès personnel au poste.
[Contrat et migration](aptitudes-beta237.md) des préférences antérieures,
avec conservation des achats et de l'XP. Build, 37 GameTests requis,
Vulkan FR/EN GUI 2/3/4 et MRpack local vérifiés. La forgerie attend ses précisions.
## PAUSE-235 — groupes côte à côte avant Atlas — essai livré localement
Branche `codex/pause-columns-beta235`, base beta.234. Avant Atlas : Aventure à
gauche, Communauté à droite et Game juste dessous, bloc centré. Avec Atlas :
une colonne étroite à gauche dans l'ordre Communauté → Aventure → Game,
carte centrale et fils à droite. [Contrat et vérifications](pause-columns-beta235.md),
parcours Vulkan Pause et Story FR/EN GUI 2/3/4, build, quatre GameTests ciblés
et MRpack local vérifiés ; rendu à apprécier.
Commit de disposition `a7bdfe6` séparé des métadonnées pour permettre son retour.
## STORY-234 — recueil personnel avec recherche — livré localement
Branche `codex/story-beta234`, base beta.233. Story sous Progression dans
+97
View File
@@ -0,0 +1,97 @@
# PAUSE-235 — Aventure et Communauté côte à côte
Branche d'essai `codex/pause-columns-beta235`, base beta.234, commit `f9c2ce8`,
Minecraft 26.3. Le rendu reste à apprécier par le créateur.
## Disposition et réversibilité
Avant Atlas, Aventure et Communauté occupent deux colonnes au même niveau :
Discovery, Progression et Story à gauche ; Habitant, Faction et Battle à droite.
Partie / Game suit juste dessous avec Options, World Options et la sortie
native. L'ensemble est centré verticalement dans le corps de Pause, avec de
l'espace sous Game ; Reprendre la partie reste dans le pied de page.
Sans Atlas, ce bloc est centré entre Gazette à gauche et Notice Board à droite.
Avec Atlas, une seule colonne étroite est serrée à gauche : Communauté, puis
Aventure, puis Partie / Game, selon l'ordre confirmé par le créateur. La carte
garde la région centrale et les deux fils communautaires restent à droite.
Le bloc complet reste centré verticalement. La largeur minimale des boutons
de navigation tient compte des libellés localisés et de leur marge native.
Aux petites hauteurs, boutons et espacements se compactent. Tab suit l'ordre
visuel : par rangée de gauche à droite avant Atlas, puis de haut en bas avec
Atlas. Les libellés FR/EN existants sont réutilisés.
Le changement de disposition et les assertions correspondantes sont isolés
dans le commit `a7bdfe6`, séparé de la livraison et de ses métadonnées. La branche
`codex/story-beta234` conserve la disposition précédente. Un retour s'effectue
avec `git revert a7bdfe6` ; une éventuelle nouvelle
livraison doit prendre le numéro beta suivant, sans diminuer le compteur.
L'application inverse du patch a été vérifiée avec `git apply --reverse --check`,
sans modifier les fichiers de travail.
Ce ticket ne change ni génération, sauvegarde, données communautaires, protocole,
conditions de progression ou contenus Story. Aucune installation personnelle
ou publication n'est demandée.
## Vérifications
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryPause233ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Le parcours Pause final réussit en 1 min 22 s (`PAUSE233_PASS`), dans un monde
plat neuf de développement, Survie sans commandes. FR/EN, GUI 2/3/4, fenêtre
1280 × 960, avant et après Atlas : six actions Sanctuary, alignement et absence
de chevauchement, largeur des libellés avec la marge native, ordre de Tab et
Game sous les groupes, avec de l'espace avant le pied. Le test attend désormais
un créneau de requête communautaire pour son amorçage et envoie le code de
raccourci natif de Tab, requis par Minecraft 26.3.
Le parcours communautaire existant passe également : publication et photo,
retours des sélecteurs, brouillons, édition d'une ancienne catégorie, inventaire
réel conservé et aucun changement du fichier communautaire pendant les lectures.
La configuration temporaire est restaurée à la fin du banc.
Log : `build/pause235-client-vulkan-final.log`. 38 captures finales copiées dans
`build/pause235-screenshots/`. Captures inspectées : sans Atlas FR/4, avec Atlas
FR/2 et EN/4. Vulkan confirmé : MoltenVK 1.4.2 sur Apple M1.
Le parcours Story final réussit en 29 s (`STORY234_PASS`), avec les mêmes
langues, GUI et états Atlas. Accès depuis les deux dispositions de Pause,
100 entrées désactivées, recherche/filtres, saisie continue et focus, molette,
redimensionnement à 1344 × 1008 et retours Échap / Terminé vérifiés. Le record
de progression serveur ne change pas. Log :
`build/pause235-story-client-vulkan-final.log` ; 21 captures copiées dans
`build/pause235-story-screenshots/`.
Aux GUI étroits, les longs libellés natifs des options/sortie et les titres des
fils gardent leur défilement ou découpage existant ; les boutons de navigation
Sanctuary montrent leur libellé entier. Les infobulles des actions Game sont
conservées. Aucun essai OpenGL, Windows ou monde personnel n'est revendiqué.
Le contrôle `check build assemblePack` réussit en 3 min 1 s : quatre GameTests
requis et contrôles purs du dépôt, dont les quatre contrôles communautaires
fichier/photos/demandes/recherche. Log : `build/pause235-check-build-pack.log`.
Le banc menus conserve sa sélection Atlas existante ; le conflit de générateur
sur le chemin natif par défaut, documenté en beta.232, reste hors de ce ticket.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.235.
Le commit de livraison conserve ces métadonnées et cette documentation séparées
du commit de disposition.
Export [Sanctuary-beta.235.mrpack](../build/Sanctuary-beta.235.mrpack),
12 776 222 octets : intégrité ZIP, Minecraft 26.3 et Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni module
de test. Les 3 303 fichiers d'assets, données et resourcepacks sont identiques
à beta.234, traductions incluses. Story reste dans le JAR de production et les
classes des bancs client en sont absentes. Reçu :
`build/pause235-mrpack-receipt.json`.
- JAR : `aad94732c749161d775ee634bdd85de70de511ef875c6c00fa167d230d0d600b`.
- MRpack : `5f1bd18aa8a4145b7e708da0abfff22068365847dddbc95ae2b490b88bbb305e`.
Aucune publication distante, modification du canal packwiz, synchronisation
Prism ou ouverture d'un monde personnel effectuée.
+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.234
pack_version=beta.234
mod_version=beta.237
pack_version=beta.237
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+4 -1
View File
@@ -228,7 +228,7 @@ tasks.named('processGametestResources') {
def villageOnly = providers.gradleProperty('sanctuaryVillageOnly').map { it.toBoolean() }.getOrElse(false)
def focused = providers.gradleProperty('sanctuaryFocusedTests').getOrElse('').split(',').findAll { !it.isEmpty() }
def focusClasses = [solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
def focusClasses = [aptitudes236: 'Aptitudes236GameTests', solo232: 'Solo232GameTests', diagonal231: 'Diagonal231GameTests', diagonal230: 'Diagonal230GameTests', diagonal229: 'Diagonal229GameTests', diagonal228: 'Diagonal228GameTests', diagonal227: 'Diagonal227GameTests', diagonal226: 'Diagonal226GameTests', diagonal225: 'Diagonal225GameTests', east224: 'East224GameTests', east223: 'East223GameTests', rock222: 'YellowRock222GameTests', west221: 'West221GameTests', west220: 'West220GameTests', west219: 'West219GameTests', west218: 'West218GameTests', west217: 'West217GameTests', south216: 'South216GameTests', relays215: 'Relays215GameTests', south214: 'South214GameTests', south213: 'South213GameTests', south212: 'South212GameTests', south211: 'South211GameTests', south210: 'South210GameTests', south209: 'South209GameTests', north208: 'North208GameTests', north207: 'North207GameTests', north206: 'North206GameTests', north205: 'North205GameTests', base204: 'Base204GameTests', palace173: 'Palace173GameTests', horde172: 'Horde172GameTests', horde171: 'Horde171GameTests', horde170: 'Horde170GameTests', horde169: 'Horde169GameTests', horde168: 'Horde168GameTests', horde167: 'Horde167GameTests', plans072: 'Plans072GameTests', starter052: 'Starter052GameTests', dispenser051: 'Dispenser051GameTests', head050: 'Head050GameTests', boatshead: 'BoatsHead049GameTests', poultry: 'Poultry048GameTests', starinspect: 'StarInspection047GameTests', skyflight: 'SkyFlight046GameTests', eggaudit: 'Companion044GameTests', headblocks: 'HeadBlocks043GameTests', operator: 'OperatorStars040GameTests', starter: 'Starter039GameTests', gravelayout: 'GraveInventory039GameTests', foodknowledge: 'FoodKnowledge039GameTests', graves: 'GravesFood038GameTests', realtime: 'Realtime037GameTests', corrections: 'Corrections036GameTests', collections: 'Collections035GameTests', notifications: 'Notifications034GameTests', menus: 'Menus033GameTests', aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests', expeditions: 'AncientExpeditions001GameTests', progression: 'Progression003GameTests', map: 'Atlas005GameTests', demeure: 'Demeure006GameTests', mining: 'Mining008GameTests', building: 'Building009GameTests', inventory: 'Inventory010GameTests', cycle: 'Cycle011GameTests', inventoryflow: 'Inventory012GameTests', sorting: 'Inventory014GameTests', tutorial: 'Tutorial016GameTests', movement: 'Movement017GameTests', accessories: 'Accessory018GameTests', companions: 'Companion019GameTests', familiar: 'Familiar020GameTests', carry: 'Carry027GameTests', familiarhit: 'Familiar029GameTests', refonte: 'Companion032GameTests', lights: 'Dynamic030GameTests', cosmetics: 'HeadCosmetic021GameTests', startup: 'StartupPerformanceGameTests', recipes: 'Recipes024GameTests']
focused.each { if (!focusClasses.containsKey(it)) throw new GradleException('Unknown focused native test: ' + it) }
inputs.property('focusedNative', focused)
def yellowRock222Client = providers.gradleProperty('sanctuaryYellowRock222ClientTests').map { it.toBoolean() }.getOrElse(false)
@@ -337,6 +337,9 @@ tasks.named('processGametestResources') {
def pause233Client = providers.gradleProperty('sanctuaryPause233ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause233ClientTests', pause233Client)
if (pause233Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause233ClientChecks') }
def aptitudes236Client = providers.gradleProperty('sanctuaryAptitudes236ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('aptitudes236Client', aptitudes236Client)
if (aptitudes236Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Aptitudes236ClientChecks') }
def story234Client = providers.gradleProperty('sanctuaryStory234ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStory234ClientTests', story234Client)
if (story234Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Story234ClientChecks') }
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.recipes.RecipeService;
import fr.koka.sanctuary.food.FoodKnowledgeService;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native Progression widgets and real server requests in a disposable Vulkan world. */
public final class Aptitudes236ClientChecks implements FabricClientGameTest {
private static final Map<String,String> TITLES=Map.ofEntries(
Map.entry("names","sanctuary.progression.names"),Map.entry("atlas","sanctuary.progression.atlas"),
Map.entry("sorting","sanctuary.progression.sorting"),Map.entry("swimming","sanctuary.progression.swimming"),
Map.entry("resting","sanctuary.progression.resting"),Map.entry("active_bond","sanctuary.companion.link"),
Map.entry("familiar_study","sanctuary.companion.study"),Map.entry("catalogue","sanctuary.recipes.catalogue"),
Map.entry("zoom","sanctuary.zoom.title"),Map.entry("enchanting","sanctuary.progression.enchanting"),Map.entry("alchemy","sanctuary.progression.alchemy"));
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
IntroClient.setQuickTestIntro(true);Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Aptitudes237 "+UUID.randomUUID());s.setSeed("237");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active() && m.player!=null && m.gui.screen()==null && !IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.setExperienceLevels(100);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==100,200);
c.runOnClient(m->{m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));check(!CraftAptitudes.learned(m.player,"enchanting") && !CraftAptitudes.learned(m.player,"alchemy"),"New workstations start locked");});
for(String craft:List.of("enchanting","alchemy")) {
c.runOnClient(m->{var button=afterTitle(m.gui.screen(),TITLES.get(craft));check(key(button).equals("sanctuary.progression.unlock_cost")&&button.active,"Locked craft offers its normal purchase button");button.onPress(enter());});
c.waitFor(m->CraftAptitudes.learned(m.player,craft) && key(afterTitle(m.gui.screen(),TITLES.get(craft))).equals("sanctuary.progression.aptitude_acquired"),200);
}
c.runOnClient(m->check(m.player.experienceLevel==92,"Two UI purchases cost eight levels"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(100);
for(String target:ProgressRecord.APTITUDES)check(ProgressionService.purchase(p,target,ProgressEffects.get(p).revision()),"Server acquires "+target);
check(CompanionService.buy(p),"Active Bond bought");check(CompanionStudy.buy(p),"Familiar Study bought");
check(RecipeService.buy(p),"Catalogue bought");check(ZoomService.buy(p),"Zoom bought");check(FoodKnowledgeService.buy(p),"Food Knowledge bought");
CompanionService.send(p,"");ProgressionService.send(p,"");
});
c.runOnClient(m->m.gui.setScreen(null));
c.waitFor(m->ProgressionClient.progress.names() && CompanionClient.studied && ZoomClient.learned() && RecipeClient.knowledge!=null && RecipeClient.knowledge.learned() && FoodKnowledgeClient.ready(),300);
c.waitTicks(30);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(AptitudeService.PREFERENCES,2044);ProgressionService.send(p,"");check(p.getAttached(AptitudeService.PREFERENCES)==0,"Legacy preferences are normalized without losing purchases");});
c.runOnClient(m->{m.player.setAttached(AptitudeService.PREFERENCES,2044);for(String target:AptitudePreferences.PERMANENT)check(AptitudeClient.enabled(target),"Client ignores legacy disabled bits for "+target);});
String purchaseProof=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
int xp=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel);
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));});
c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();check(screen.isPauseScreen(),"Progression retains Pause semantics");layout(screen);var titles=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).map(Aptitudes236ClientChecks::key).toList();
int previous=-1;for(String theme:List.of("survival","inventory_crafting","familiars","exploration","crafts")){int index=titles.indexOf("sanctuary.progression.theme_"+theme);check(index>previous,"All five themes are present in order");previous=index;}
check(has(screen,"sanctuary.progression.aptitude_permanent"),"Food Knowledge is visibly permanent");
check(key(afterTitle(screen,"sanctuary.food_knowledge.title")).equals("sanctuary.food_knowledge.learned") && !afterTitle(screen,"sanctuary.food_knowledge.title").active,"Food Knowledge has no activation switch");
for(var entry:TITLES.entrySet()){var button=afterTitle(screen,entry.getValue());boolean toggle=AptitudePreferences.TOGGLEABLE.contains(entry.getKey());
check(key(button).equals(toggle?"sanctuary.progression.aptitude_enabled":"sanctuary.progression.aptitude_acquired") && button.active==toggle,"Only World Map and Mob Names have switches: "+entry.getKey());check(m.font.width(button.getMessage())+8<=button.getWidth(),"Aptitude label fits without clipping");}
});
shot(c,"top-"+language+"-"+scale);
for(String target:AptitudePreferences.TOGGLEABLE) {
c.runOnClient(m->{var screen=m.gui.screen();var button=afterTitle(screen,TITLES.get(target));reveal(screen,button);check(button.active,"Toggle is ready: "+target);button.onPress(enter());});
c.waitFor(m->!AptitudeClient.enabled(target) && key(afterTitle(m.gui.screen(),TITLES.get(target))).equals("sanctuary.progression.aptitude_disabled"),200);
check(server.computeOnServer(s->!AptitudeService.enabled(s.getPlayerList().getPlayers().getFirst(),target)),"UI choice is authoritative: "+target);
if(target.equals("atlas")) {
c.runOnClient(m->{m.gui.setScreen(new PauseScreen(true));pauseLayout(m.gui.screen(),false);});shot(c,"pause-map-disabled-"+language+"-"+scale);
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(new PauseScreen(true))));
}
c.runOnClient(m->afterTitle(m.gui.screen(),TITLES.get(target)).onPress(enter()));
c.waitFor(m->AptitudeClient.enabled(target) && key(afterTitle(m.gui.screen(),TITLES.get(target))).equals("sanctuary.progression.aptitude_enabled"),200);
}
c.runOnClient(m->{m.gui.setScreen(new PauseScreen(true));pauseLayout(m.gui.screen(),true);});shot(c,"pause-map-enabled-"+language+"-"+scale);
c.runOnClient(m->{m.gui.setScreen(new ProgressionScreen(new PauseScreen(true)));reveal(m.gui.screen(),afterTitle(m.gui.screen(),TITLES.get("alchemy")));});
shot(c,"crafts-"+language+"-"+scale);
c.runOnClient(m->{var screen=m.gui.screen();var area=aptitudes(screen);area.setScrollAmount(100);});
c.runOnClient(m->{m.getWindow().setWindowed(1344,1008);m.resizeGui();});c.waitTicks(3);
c.runOnClient(m->{check(aptitudes(m.gui.screen()).scrollAmount()==100,"Resize retains aptitude scroll");layout(m.gui.screen());press(m.gui.screen(),"gui.done");check(m.gui.screen() instanceof PauseScreen,"Done returns to Pause");m.gui.setScreen(null);});
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.experienceLevel==xp && p.getAttached(ProgressionService.RECORD).equals(purchaseProof),"Twelve disable/reactivate pairs preserve XP and purchases");});
System.out.println("APTITUDES237_PASS: Vulkan FR/EN GUI 2/3/4, five themes, two real server toggles, ten permanent purchases, legacy preferences and merged Pause layout, two native UI purchases, preserved XP/purchases, scroll/resize and Done");
}finally{intro.set(false);}
}
private static void pauseLayout(Screen screen,boolean enabled) {Pause233ClientChecks.pause(screen,enabled);}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static Button afterTitle(Screen screen,String title) {
var list=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList();
int index=java.util.stream.IntStream.range(0,list.size()).filter(i->key(list.get(i)).equals(title)).findFirst().orElseThrow(()->new AssertionError("Missing title "+title));
return list.subList(index+1,list.size()).stream().filter(Button.class::isInstance).map(Button.class::cast).findFirst().orElseThrow();
}
private static AbstractScrollArea aptitudes(Screen screen){return widgets(screen).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).max(Comparator.comparingInt(AbstractWidget::getX)).orElseThrow();}
private static void reveal(Screen screen,AbstractWidget widget){var area=aptitudes(screen);area.setScrollAmount(Math.clamp(area.scrollAmount()+widget.getBottom()-area.getBottom()+4,0,area.maxScrollAmount()));}
private static void layout(Screen screen){var area=aptitudes(screen);check(area.getX()>=0&&area.getRight()<=screen.width&&area.getBottom()<screen.height-26,"Aptitudes viewport stays inside native frame");for(var button:widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).toList())check(button.getX()>=0&&button.getRight()<=screen.width,"Buttons remain within the screen horizontally");}
private static void shot(ClientGameTestContext c,String name){var source=c.takeScreenshot("aptitudes237-"+name);try{var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/aptitudes237-screenshots").normalize();Files.createDirectories(dir);Files.copy(source,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}}
}
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.client;
import com.google.gson.Gson;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
@@ -15,6 +16,7 @@ import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.*;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
@@ -46,7 +48,8 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().setItem(6,new ItemStack(Items.OAK_LOG,17));p.getInventory().setItem(7,new ItemStack(Items.DIAMOND,3));});
String legacy=UUID.randomUUID().toString();
c.runOnClient(m->check(CommunityClient.request(null,"create",legacy,"","notice","event","Ancienne annonce","Texte historique",0,0,r->check(!r.has("error"),"Legacy-compatible notice seeded through the server")),"Seed request dispatched"));
// Wait and dispatch together so the periodic following request cannot take the slot between them.
c.waitFor(m->CommunityClient.request(null,"create",legacy,"","notice","event","Ancienne annonce","Texte historique",0,0,r->check(!r.has("error"),"Legacy-compatible notice seeded through the server")),200);
c.waitFor(m->!CommunityClient.pending(),200);c.waitTicks(24);
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Du bois pour le pont");body(m.gui.screen()).setValue("Une annonce commune, avec une demande facultative.");composer(m.gui.screen(),"notice");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");});
@@ -86,7 +89,7 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
}
}
server.runOnServer(s->checked(()->{check(Arrays.equals(saved,Files.readAllBytes(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"))),"Reading/resizing old and new posts never rewrites stored categories");return null;}));
System.out.println("PAUSE233_PASS: FR/EN GUI 2/3/4, identity/faction/battle at top, discovery/progression centered, options below, unified notice composer, descriptive task and photo publication, legacy category preservation");
System.out.println("PAUSE233_PASS: FR/EN GUI 2/3/4, parallel Adventure/Community before Atlas, narrow Community/Adventure stack beside Atlas, Game directly below, centered navigation block, visual Tab order, unified notice composer, descriptive task and photo publication, legacy category preservation");
}finally {intro.set(false);checked(()->{if(previous==null)Files.deleteIfExists(config);else Files.write(config,previous);Files.deleteIfExists(fixture);return null;});}
}
private FileCommunityStore store(net.minecraft.server.MinecraftServer s)throws Exception{return new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),serverId);}
@@ -105,19 +108,40 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
check((((PauseAtlasAccess)s).sanctuary$pauseMap()!=null)==unlocked,"Pause map appears only after Atlas purchase");
var keys=Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title");
var nav=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->keys.contains(key(b))).toList();check(nav.size()==6,"Six Sanctuary navigation actions");
int top=nav.stream().mapToInt(AbstractWidget::getY).min().orElseThrow(),bottom=nav.stream().mapToInt(AbstractWidget::getBottom).max().orElseThrow();
int options=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("menu.options")).findFirst().orElseThrow().getY();
check(bottom+8<=options,"Navigation stays separated from Options/World Options/Quit");
var options=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("menu.options")).findFirst().orElseThrow();
var worldOptions=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("options.worldOptions.button")).findFirst().orElseThrow();
var quit=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("menu.returnToMenu","menu.disconnect").contains(key(b))).findFirst().orElseThrow();
var community=widget(s,"pause_group_community");var adventure=widget(s,"pause_group_adventure");var game=widget(s,"pause_group_game");
check(community.getY()==(s.height<280?46:50)&&community.getBottom()<nav.get(0).getY()&&adventure.getBottom()<nav.get(3).getY()&&game.getBottom()<options,"All three headings precede their buttons");
check(nav.stream().map(CommunityPausePanels::key).toList().equals(List.of("sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title")),"Same visual and keyboard order with or without the map");
check(nav.get(0).getBottom()<nav.get(1).getY()&&nav.get(1).getBottom()<nav.get(2).getY()&&nav.get(2).getBottom()<adventure.getY()&&nav.get(3).getBottom()<nav.get(4).getY()&&nav.get(4).getBottom()<nav.get(5).getY()&&nav.get(5).getBottom()<game.getY(),"Three distinct titled groups without overlap");
double center=(nav.get(3).getY()+nav.get(5).getBottom())/2.;check(Math.abs(center-s.height/2.)<=.5,"Discovery/progression/story are vertically centered");
check(nav.stream().map(CommunityPausePanels::key).toList().equals(unlocked
?List.of("sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title")
:List.of("sanctuary.progression.blocks","sanctuary.progression.identity","sanctuary.progression.progress","sanctuary.cycle.faction","sanctuary.story.title","sanctuary.arena.title")),"Navigation follows visual order in both layouts");
for(var button:nav)check(s.getFont().width(button.getMessage())+8<=button.getWidth(),"Navigation labels fit with native button padding");
check(nav.stream().allMatch(b->b.getBottom()<game.getY())&&game.getBottom()<options.getY()&&options.getBottom()<worldOptions.getY()&&worldOptions.getBottom()<quit.getY(),"Game follows below Sanctuary navigation");
check(options.getX()==nav.get(0).getX()&&worldOptions.getX()==options.getX()&&quit.getX()==options.getX()&&worldOptions.getWidth()==options.getWidth()&&quit.getWidth()==options.getWidth(),"Native Game actions share the navigation block width");
int bodyTop=s.height<280?46:50,bodyBottom=s.height-39;
check(Math.abs((Math.min(adventure.getY(),community.getY())+quit.getBottom())/2.-(bodyTop+bodyBottom)/2.)<=.5&&quit.getBottom()+8<=bodyBottom,"Complete block centered with clear space below Game");
// KeyEvent carries the physical key and the SDL shortcut key separately.
s.setFocused(nav.get(0));s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(1),"Tab follows the second visible action");
s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(2),"Tab follows the third visible action");
if(!unlocked){
for(var b:nav)check(Math.abs(b.getX()+b.getWidth()/2.-s.width/2.)<=.5,"Navigation centered horizontally");
check(widget(s,"article").getX()==8&&widget(s,"notice").getX()>nav.getFirst().getRight(),"Gazette remains left and Notice Board right");
check(adventure.getY()==community.getY()&&adventure.getX()==nav.get(0).getX()&&community.getX()==nav.get(1).getX()&&adventure.getBottom()<nav.get(0).getY(),"Both headings align above their columns");
for(int row=0;row<3;row++) {
var left=nav.get(2*row);var right=nav.get(2*row+1);
check(left.getY()==right.getY()&&left.getHeight()==right.getHeight()&&left.getRight()<right.getX(),"Two parallel columns without overlap");
if(row>0)check(nav.get(2*row-2).getBottom()<left.getY(),"Rows remain separated");
}
check(options.getRight()==nav.get(1).getRight(),"Game spans both columns before Atlas");
check(Math.abs((nav.get(0).getX()+nav.get(1).getRight())/2.-s.width/2.)<=.5,"Two-column block centered horizontally");
check(widget(s,"article").getX()==8&&widget(s,"article").getRight()<nav.get(0).getX()&&widget(s,"notice").getX()>nav.get(1).getRight(),"Gazette remains left and Notice Board right");
}
else {
check(community.getBottom()<nav.get(0).getY()&&nav.get(2).getBottom()<adventure.getY()&&adventure.getBottom()<nav.get(3).getY(),"Community then Adventure stacked above Game with Atlas");
for(int i=0;i<nav.size();i++) {
var button=nav.get(i);check(button.getX()==8&&button.getWidth()==options.getWidth()&&button.getWidth()<=132,"All actions use one narrow left column");
if(i>0)check(nav.get(i-1).getBottom()<button.getY(),"Stacked buttons remain separated");
}
var map=((PauseAtlasAccess)s).sanctuary$pauseMap();check(nav.get(0).getRight()<map.getX()&&map.getRight()<widget(s,"article").getX()&&widget(s,"article").getX()==widget(s,"notice").getX(),"Narrow navigation left, map center and community feeds right");
}
else check(nav.getFirst().getX()==8&&widget(s,"article").getX()==widget(s,"notice").getX(),"Unlocked map retains left navigation and community feeds on the right");
}
private void shot(ClientGameTestContext c,String name){var path=c.takeScreenshot("pause233-"+name);checked(()->{var dir=game.resolve("../../../../../build/pause233-screenshots").normalize();Files.createDirectories(dir);Files.copy(path,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);return null;});}
private interface Checked<T>{T run()throws Exception;}
@@ -54,7 +54,8 @@ public final class Solo232ClientChecks implements FabricClientGameTest {
var article=heading(screen,"sanctuary.community.article");var notice=heading(screen,"sanctuary.community.notice");
if(!unlocked){check(article.getX()==8&&article.getY()==(screen.height<280?46:50)&&notice.getY()==article.getY(),"Locked map uses full-height Gazette on the left and Notice Board on the right");for(var button:buttons)check(article.getRight()<button.getX()&&notice.getX()>button.getRight(),"Community columns do not overlap centered navigation");}
else check(article.getX()==notice.getX()&&article.getY()<notice.getY(),"Unlocked map retains both community feeds stacked on the right");
for(var button:buttons){check(button.getY()>=50&&button.getBottom()<=screen.height-33,"Button stays in pause body");if(!unlocked)check(Math.abs(button.getX()+button.getWidth()/2.-screen.width/2.)<=.5,"Locked-map navigation is horizontally centered");else check(button.getX()==8,"Unlocked layout retains original left column");}
for(var button:buttons)check(button.getY()>=50&&button.getBottom()<=screen.height-33,"Button stays in pause body");
Pause233ClientChecks.pause(screen,unlocked);
});
String shot="solo232-pause-"+(unlocked?"unlocked-":"locked-")+language+"-"+scale;
var image=c.takeScreenshot(shot);checked(()->{var dir=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/solo232-screenshots").normalize();java.nio.file.Files.createDirectories(dir);java.nio.file.Files.copy(image,dir.resolve(shot+".png"),java.nio.file.StandardCopyOption.REPLACE_EXISTING);});
@@ -0,0 +1,129 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.atlas.AtlasService;
import java.util.UUID;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.network.chat.Component;
/** Behavioral checks on real server players, native menus and vanilla persistence. */
public final class Aptitudes236GameTests {
@GameTest public void preferencesPreservePurchasesXpDeathAndCycle(GameTestHelper h) {
var record=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS)while(record.rank(skill)<ProgressRecord.cap(skill))record=record.buy(skill,record.cost(skill,ProgressionConfig.DEFAULT));
for(String aptitude:ProgressRecord.APTITUDES)record=record.buy(aptitude,4);
var p=player(h,record);p.setExperienceLevels(20);
p.setAttached(CompanionService.RECORD,CompanionRecord.EMPTY.purchase(4,1).json());
p.setAttached(CompanionStudy.RECEIPT,"{\"schema\":1,\"paid\":4,\"at\":1}");
p.setAttached(ZoomService.LEARNED,true);
h.assertTrue(RecipeService.buy(p),"Catalogue acquired through its normal server purchase");
h.assertTrue(CraftAptitudes.buy(p,"enchanting",record.revision()) && CraftAptitudes.buy(p,"alchemy",record.revision()),"New craft purchases accepted");
String proof=p.getAttachedOrThrow(ProgressionService.RECORD),recipes=p.getAttached(RecipeService.RECORD),bond=p.getAttached(CompanionService.RECORD);
int xp=p.experienceLevel;
h.assertTrue(!p.hasAttached(AptitudeService.PREFERENCES),"Old players need no preference migration");
for(String target:AptitudePreferences.TOGGLEABLE)h.assertTrue(toggle(p,target,false),"Owned aptitude can be disabled: "+target);
h.assertTrue(AptitudeService.preferences(p).disabledMask()==AptitudePreferences.ACTIVE_MASK && p.experienceLevel==xp,"Both display choices are independent and free");
h.assertTrue(proof.equals(p.getAttached(ProgressionService.RECORD)) && recipes.equals(p.getAttached(RecipeService.RECORD)) && bond.equals(p.getAttached(CompanionService.RECORD)),"Purchase proofs remain byte-for-byte unchanged");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(AptitudeService.preferences(loaded).equals(AptitudeService.preferences(p)) && CraftAptitudes.learned(loaded,"enchanting") && CraftAptitudes.learned(loaded,"alchemy"),"Choices and craft purchases reload from vanilla player NBT");
var respawn=Progression003GameTests.player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);
ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(AptitudeService.preferences(respawn).equals(AptitudeService.preferences(p)) && CraftAptitudes.learned(respawn,"alchemy"),"Choices and new purchases survive ordinary death");
bind(p,record.nextCycle());
h.assertTrue(AptitudeService.preferences(p).disabledMask()==AptitudePreferences.ACTIVE_MASK && CraftAptitudes.learned(p,"enchanting"),"Skill reset preserves separate preferences and craft purchases");
for(String target:AptitudePreferences.TOGGLEABLE)h.assertTrue(toggle(p,target,true),"Reactivation needs no new purchase: "+target);
h.assertTrue(p.experienceLevel==xp && AptitudeService.preferences(p).equals(AptitudePreferences.DEFAULT),"Reactivation costs no XP");h.succeed();
}
@GameTest public void unknownUnownedPermanentAndStaleRequestsRefused(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL.buy("names",4));p.setExperienceLevels(10);
for(String target:new String[]{"swimming","enchanting","alchemy","food_knowledge","unknown","vitality"})
h.assertTrue(!toggle(p,target,false),"Cannot toggle unowned, permanent or non-aptitude target: "+target);
h.assertTrue(toggle(p,"names",false),"Owned name reveal disables");
h.assertTrue(!AptitudeService.set(p,new AptitudeService.Set("names",true,0)),"Stale mask cannot overwrite a newer choice");
h.assertTrue(!CraftAptitudes.buy(p,"enchanting",99) && !CraftAptitudes.buy(p,"unknown",1),"Stale and unknown purchases refused");
p.setExperienceLevels(3);h.assertTrue(!CraftAptitudes.buy(p,"alchemy",1) && p.experienceLevel==3,"Insufficient XP never spent");
p.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(p,"alchemy",1) && p.experienceLevel==0 && !CraftAptitudes.buy(p,"alchemy",1),"Four levels buy once");
h.assertTrue(!AptitudeService.enabled(p,"names"),"Refusal preserves previous choice");h.succeed();
}
@GameTest public void legacyPreferencesRestorePermanentPurchases(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL.buy("names",4).buy("atlas",4));p.setExperienceLevels(12);
h.assertTrue(CraftAptitudes.buy(p,"enchanting",2) && CraftAptitudes.buy(p,"alchemy",2),"Craft purchases exist before legacy migration");
p.setAttached(AptitudeService.PREFERENCES,AptitudePreferences.MAX_MASK);
String proof=p.getAttached(ProgressionService.RECORD);int xp=p.experienceLevel;
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(loaded.getAttachedOrThrow(AptitudeService.PREFERENCES)==2047,"Historical codec still reloads all eleven bits");
h.assertTrue(AptitudeService.preferences(loaded).disabledMask()==3 && !AptitudeService.enabled(loaded,"names") && !AptitudeService.enabled(loaded,"atlas"),"Legacy display choices retain their original bit positions");
for(String target:AptitudePreferences.PERMANENT) {
h.assertTrue(AptitudeService.enabled(loaded,target),"Legacy permanent preference has no effect: "+target);
h.assertTrue(!toggle(p,target,false),"Permanent aptitude cannot be disabled: "+target);
}
AptitudeService.send(loaded);
h.assertTrue(loaded.getAttachedOrThrow(AptitudeService.PREFERENCES)==3 && loaded.experienceLevel==xp && proof.equals(loaded.getAttached(ProgressionService.RECORD)),"Sync normalizes only preferences, preserving XP and purchase proofs");
h.assertTrue(CraftAptitudes.learned(loaded,"enchanting") && CraftAptitudes.learned(loaded,"alchemy"),"Migration retains both craft purchases");h.succeed();
}
@GameTest public void permanentServerEffectsIgnoreLegacyDisabledBits(GameTestHelper h) {
var r=ProgressRecord.INITIAL.buy("hunger",1).buy("swimming",4).buy("resting",4).buy("sorting",4).buy("atlas",4);
var p=player(h,r);p.getFoodData().setFoodLevel(8);p.setAttached(AptitudeService.PREFERENCES,2044);
var at=p.blockPosition();for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)for(int y=0;y<=3;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
p.baseTick();p.baseTick();p.setSprinting(true);p.setSwimming(true);
h.assertTrue(p.isSwimming() && !toggle(p,"swimming",false) && p.isSwimming() && ProgressEffects.canSwim(p),"Swimming stays usable with an old disabled bit and a rejected request");
for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)for(int y=0;y<=3;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.AIR.defaultBlockState());
p.baseTick();p.baseTick();p.setOnGround(true);
h.assertTrue(LyingService.request(p,new LyingService.Request(2,r.revision())),"Purchased Rest ignores its legacy disabled bit");
h.assertTrue(!toggle(p,"resting",false) && LyingService.posture(p)==2,"Refused disable request never stops Rest");
h.assertTrue(InventorySortService.sort(p,new InventorySortPayloads.Sort(1,p.containerMenu.containerId,p.containerMenu.getStateId(),r.revision(),InventorySortPayloads.HOTBAR)),"Purchased inventory sorting ignores its legacy disabled bit");
h.assertTrue(AtlasService.canRead(p,false) && toggle(p,"atlas",false) && !AtlasService.canRead(p,false),"Personal World Map access follows the display choice");
h.assertTrue(toggle(p,"atlas",true) && AtlasService.canRead(p,false),"World Map reactivates with its existing purchase");
LyingService.request(p,new LyingService.Request(0,r.revision()));
p.setAttached(CompanionService.RECORD,CompanionRecord.EMPTY.purchase(4,1).json());
h.assertTrue(!toggle(p,"active_bond",false) && CompanionService.activate(p,-1).equals("no_familiar"),"Active Bond reaches the normal familiar requirement despite its legacy disabled bit");h.succeed();
}
@GameTest public void enchantingNativeMenuRequiresPermanentPurchase(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);p.setAttached(AptitudeService.PREFERENCES,2044);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at,Blocks.ENCHANTING_TABLE.defaultBlockState());
var provider=new SimpleMenuProvider((id,inventory,player)->new EnchantmentMenu(id,inventory,ContainerLevelAccess.create(h.getLevel(),at)),Component.literal("Enchanting"));
h.assertTrue(p.openMenu(provider).isEmpty() && p.containerMenu==p.inventoryMenu,"Unlearned player cannot open a native enchanting menu");
p.setExperienceLevels(40);h.assertTrue(CraftAptitudes.buy(p,"enchanting",0) && p.experienceLevel==36,"Unlock charges exactly four levels");
h.assertTrue(p.openMenu(provider).isPresent(),"Native menu opens after purchase");var menu=(EnchantmentMenu)p.containerMenu;
menu.getSlot(0).set(new ItemStack(Items.DIAMOND_SWORD));menu.getSlot(1).set(new ItemStack(Items.LAPIS_LAZULI,3));
h.assertTrue(menu.clickMenuButton(p,0) && !menu.getSlot(0).getItem().get(DataComponents.ENCHANTMENTS).isEmpty(),"Real vanilla enchanting remains functional");
int xp=p.experienceLevel;
h.assertTrue(!toggle(p,"enchanting",false) && p.containerMenu==menu && menu.stillValid(p) && p.experienceLevel==xp,"Purchased enchanting cannot be disabled or closed by a toggle request");
p.closeContainer();h.assertTrue(p.openMenu(provider).isPresent(),"Enchanting remains available on later visits");p.closeContainer();h.succeed();
}
@GameTest public void alchemyNativeMenuRetainsSharedInventory(GameTestHelper h) {
var p=player(h,ProgressRecord.INITIAL);p.setAttached(AptitudeService.PREFERENCES,2044);var at=p.blockPosition();h.getLevel().setBlockAndUpdate(at,Blocks.BREWING_STAND.defaultBlockState());
var stand=(net.minecraft.world.level.block.entity.BrewingStandBlockEntity)h.getLevel().getBlockEntity(at);
h.assertTrue(p.openMenu(stand).isEmpty(),"Unlearned player cannot open brewing stand");
p.setExperienceLevels(4);h.assertTrue(CraftAptitudes.buy(p,"alchemy",0) && p.openMenu(stand).isPresent(),"Alchemy unlock opens the native stand");
stand.setItem(3,new ItemStack(Items.NETHER_WART));stand.setItem(4,new ItemStack(Items.BLAZE_POWDER));
var menu=p.containerMenu;h.assertTrue(!toggle(p,"alchemy",false) && p.containerMenu==menu && menu.stillValid(p),"Alchemy cannot be disabled or closed by a toggle request");
h.assertTrue(stand.getItem(3).is(Items.NETHER_WART) && stand.getItem(4).is(Items.BLAZE_POWDER),"Shared ingredients are never removed");
p.closeContainer();h.assertTrue(p.openMenu(stand).isPresent(),"Purchased Alchemy stays available on later visits");
p.closeContainer();var vanilla=Progression003GameTests.player(h);
h.assertTrue(CraftAptitudes.allowed(vanilla,"enchanting") && CraftAptitudes.allowed(vanilla,"alchemy"),"Inactive vanilla players retain native access");h.succeed();
}
private static boolean toggle(ServerPlayer p,String target,boolean enabled) {return AptitudeService.set(p,new AptitudeService.Set(target,enabled,AptitudeService.preferences(p).disabledMask()));}
private static ServerPlayer player(GameTestHelper h,ProgressRecord record) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());
try {var identities=ProgressionService.session(h.getLevel().getServer()).inhabitants;identities.create(p.getUUID(),"apt"+p.getUUID().toString().substring(0,8),"",identities.available().getFirst());}
catch(Exception e){throw new AssertionError(e);}
bind(p,record);return p;
}
private static void bind(ServerPlayer p,ProgressRecord record){p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);}
}
@@ -8,7 +8,7 @@
"entrypoints": {
"client": ["fr.koka.sanctuary.gametest.Starter039TestSupport"],
"preLaunch": ["fr.koka.sanctuary.gametest.Inventory010MixinAudit"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests"],
"fabric-gametest": ["fr.koka.sanctuary.gametest.Aptitudes236GameTests","fr.koka.sanctuary.gametest.Diagonal231GameTests", "fr.koka.sanctuary.gametest.Diagonal230GameTests", "fr.koka.sanctuary.gametest.Diagonal229GameTests", "fr.koka.sanctuary.gametest.Diagonal228GameTests", "fr.koka.sanctuary.gametest.Diagonal227GameTests", "fr.koka.sanctuary.gametest.Diagonal226GameTests", "fr.koka.sanctuary.gametest.Diagonal225GameTests", "fr.koka.sanctuary.gametest.East224GameTests", "fr.koka.sanctuary.gametest.East223GameTests", "fr.koka.sanctuary.gametest.YellowRock222GameTests", "fr.koka.sanctuary.gametest.West221GameTests", "fr.koka.sanctuary.gametest.West220GameTests", "fr.koka.sanctuary.gametest.West219GameTests", "fr.koka.sanctuary.gametest.West218GameTests", "fr.koka.sanctuary.gametest.West217GameTests", "fr.koka.sanctuary.gametest.Base204GameTests", "fr.koka.sanctuary.gametest.North205GameTests", "fr.koka.sanctuary.gametest.Palace173GameTests", "fr.koka.sanctuary.gametest.Plans072GameTests", "fr.koka.sanctuary.gametest.Starter052GameTests", "fr.koka.sanctuary.gametest.Dispenser051GameTests", "fr.koka.sanctuary.gametest.Head050GameTests", "fr.koka.sanctuary.gametest.Poultry048GameTests", "fr.koka.sanctuary.gametest.StarInspection047GameTests", "fr.koka.sanctuary.gametest.SkyFlight046GameTests", "fr.koka.sanctuary.gametest.GravesFood038GameTests","fr.koka.sanctuary.gametest.Realtime037GameTests","fr.koka.sanctuary.gametest.Corrections036GameTests","fr.koka.sanctuary.gametest.Collections035GameTests","fr.koka.sanctuary.gametest.Notifications034GameTests","fr.koka.sanctuary.gametest.Menus033GameTests","${gametest_entrypoint}", "fr.koka.sanctuary.gametest.Swamp27WorldGameTests", "fr.koka.sanctuary.gametest.Caves28WorldGameTests", "fr.koka.sanctuary.gametest.Terraces29WorldGameTests", "fr.koka.sanctuary.gametest.River30WorldGameTests", "fr.koka.sanctuary.gametest.Transit29WorldGameTests", "fr.koka.sanctuary.gametest.Alpha28NativeStructuresGameTests", "fr.koka.sanctuary.gametest.Village306GameTests", "fr.koka.sanctuary.gametest.Drained307GameTests", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests", "fr.koka.sanctuary.gametest.AncientExpeditions001GameTests", "fr.koka.sanctuary.gametest.Progression003GameTests", "fr.koka.sanctuary.gametest.Atlas005GameTests", "fr.koka.sanctuary.gametest.Demeure006GameTests", "fr.koka.sanctuary.gametest.Mining008GameTests", "fr.koka.sanctuary.gametest.Building009GameTests", "fr.koka.sanctuary.gametest.Inventory010GameTests", "fr.koka.sanctuary.gametest.Cycle011GameTests", "fr.koka.sanctuary.gametest.Inventory012GameTests", "fr.koka.sanctuary.gametest.Inventory014GameTests", "fr.koka.sanctuary.gametest.Tutorial016GameTests", "fr.koka.sanctuary.gametest.Movement017GameTests", "fr.koka.sanctuary.gametest.Accessory018GameTests", "fr.koka.sanctuary.gametest.Companion019GameTests", "fr.koka.sanctuary.gametest.Companion032GameTests", "fr.koka.sanctuary.gametest.Recipes024GameTests", "fr.koka.sanctuary.gametest.Carry027GameTests", "fr.koka.sanctuary.gametest.Familiar029GameTests", "fr.koka.sanctuary.gametest.Dynamic030GameTests", "fr.koka.sanctuary.gametest.GraveInventory039GameTests", "fr.koka.sanctuary.gametest.FoodKnowledge039GameTests", "fr.koka.sanctuary.gametest.OperatorStars040GameTests", "fr.koka.sanctuary.gametest.Starter039GameTests", "fr.koka.sanctuary.gametest.Horde167GameTests", "fr.koka.sanctuary.gametest.Horde168GameTests", "fr.koka.sanctuary.gametest.Horde169GameTests", "fr.koka.sanctuary.gametest.Horde170GameTests", "fr.koka.sanctuary.gametest.Horde171GameTests", "fr.koka.sanctuary.gametest.Horde172GameTests"],
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
},
"mixins": ["sanctuary-gametest.mixins.json"],
@@ -44,7 +44,7 @@ public final class AtlasService {
var session=ProgressionService.session(player.level().getServer());
var record=ProgressEffects.get(player);
return session!=null && session.healthy && player.level().dimension()==Level.OVERWORLD
&& (operator ? canOperate(player) : record!=null && record.atlas());
&& (operator ? canOperate(player) : record!=null && record.atlas() && fr.koka.sanctuary.progression.AptitudeService.enabled(player,"atlas"));
}
private static State state(ServerPlayer player) {
var server=player.level().getServer();
@@ -0,0 +1,53 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.layouts.LinearLayout;
public final class AptitudeClient {
private static boolean ready;
private static long pendingUntil;
private AptitudeClient() {}
public static boolean enabled(String target) {
var p=Minecraft.getInstance().player;
return p==null || AptitudeService.enabled(p,target);
}
public static void register() {
ClientConfigurationNetworking.registerGlobalReceiver(AptitudeService.Capability.TYPE,(p,c)->{});
ClientConfigurationNetworking.registerGlobalReceiver(AptitudeService.PermanentCapability.TYPE,(p,c)->{});
ClientPlayNetworking.registerGlobalReceiver(AptitudeService.State.TYPE,(state,c)->c.client().execute(()->{
var player=c.client().player;
if(player==null || player.getId()!=state.entity())return;
var value=new AptitudePreferences(state.disabledMask());
boolean mapChanged=enabled("atlas")!=value.enabled("atlas");
player.setAttached(AptitudeService.PREFERENCES,value.disabledMask());
player.setAttached(CraftAptitudes.ENCHANTING,state.enchanting());
player.setAttached(CraftAptitudes.ALCHEMY,state.alchemy());ready=true;pendingUntil=0;
if(mapChanged && c.client().gui.screen() instanceof SanctuaryAtlasScreen map)map.permissionsChanged();
if(c.client().gui.screen() instanceof ProgressionScreen screen)screen.accept("");
}));
ClientPlayConnectionEvents.DISCONNECT.register((h,c)->reset());
ClientConfigurationConnectionEvents.INIT.register((h,c)->reset());
}
private static void reset(){ready=false;pendingUntil=0;}
public static void acquired(LinearLayout group,int width) {
var button=group.addChild(Button.builder(ProgressionService.label("aptitude_acquired"),b->{}).width(width)
.tooltip(Tooltip.create(ProgressionService.label("aptitude_acquired_hint"))).build());
button.active=false;
}
public static void toggle(LinearLayout group,int width,String target,boolean learned) {
if(!AptitudePreferences.TOGGLEABLE.contains(target))throw new IllegalArgumentException("Permanent aptitude has no switch");
var button=group.addChild(Button.builder(ProgressionService.label(enabled(target)?"aptitude_enabled":"aptitude_disabled"),b->{
var player=Minecraft.getInstance().player;
if(player==null || !ready || System.nanoTime()<pendingUntil || !ClientPlayNetworking.canSend(AptitudeService.Set.TYPE))return;
pendingUntil=System.nanoTime()+4_000_000_000L;b.active=false;
if(Minecraft.getInstance().gui.screen() instanceof ProgressionScreen screen)screen.waitForResponse();
ClientPlayNetworking.send(new AptitudeService.Set(target,!enabled(target),AptitudeService.preferences(player).disabledMask()));
}).width(width).tooltip(Tooltip.create(ProgressionService.label(enabled(target)?"aptitude_disable_hint":"aptitude_enable_hint")
.copy().append("\n").append(ProgressionService.label("aptitude_toggle_hint")))).build());
button.active=learned && ready && System.nanoTime()>=pendingUntil;
}
}
@@ -24,35 +24,44 @@ public final class CommunityPausePanels implements AutoCloseable {
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
this.screen=screen;
boolean compact=screen.height<280;
boolean unlocked=ProgressionClient.progress.atlas() && AptitudeClient.enabled("atlas");
int margin=8,gap=10,top=compact?46: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;
boolean unlocked=ProgressionClient.progress.atlas();
int menuWidth=unlocked?left:Math.max(left,Math.min(200,2*(rightX-gap)-screen.width));
String[] sections={"blocks","identity","progress","faction","story","battle"};
int columnGap=compact?4:8;
int minimumWidth=Arrays.stream(sections).map(CommunityPausePanels::sectionLabel).mapToInt(Minecraft.getInstance().font::width).max().orElse(0)+8;
int columnWidth=Math.max(minimumWidth,(Math.min(264,screen.width/3)-columnGap)/2);
int menuWidth=unlocked?Math.max(minimumWidth,Math.min(132,Math.max(76,screen.width/5))):2*columnWidth+columnGap;
int right=unlocked?Math.min(244,Math.max(104,screen.width*31/100))
:Math.min(244,(screen.width-2*margin-menuWidth-2*gap)/2);
int centerX=margin+menuWidth+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
int menuX=unlocked?margin:(screen.width-menuWidth)/2;
int optionHeight=compact?14:20,navGap=compact?1:4,groupGap=compact?2:12,titleHeight=compact?10:12;
int optionsTop=bottom-(3*optionHeight+8);
int navHeight=Math.min(20,Math.max(11,(optionsTop-top-3*titleHeight-4*navGap-2*groupGap)/6));
// Leave room for Community and its heading above the centered Adventure group.
navHeight=Math.min(navHeight,Math.max(11,(screen.height-2*top-4*titleHeight-6*navGap-2*groupGap)/9));
int communityX=unlocked?menuX:menuX+columnWidth+columnGap;
int navGap=compact?(unlocked?1:2):4,groupGap=compact?(unlocked?2:8):12,titleHeight=compact&&unlocked?10:12;
int groupCount=unlocked?3:2;
int navHeight=Math.min(compact?14:20,Math.max(11,(bottom-top-16-groupCount*titleHeight-(groupCount-1)*groupGap-2*groupCount*navGap)/(3*groupCount)));
int step=navHeight+navGap;
int upperTop=top+titleHeight;
int adventureSize=3*navHeight+2*navGap;
int adventureTop=Math.max(upperTop+2*step+navHeight+groupGap+titleHeight,Math.min((screen.height-adventureSize)/2,optionsTop-titleHeight-adventureSize-groupGap));
int sectionHeight=titleHeight+3*navHeight+2*navGap;
int menuHeight=groupCount*sectionHeight+(groupCount-1)*groupGap;
// Use a narrow stack beside the map, or parallel groups before Atlas.
int menuTop=top+Math.max(0,(bottom-top-menuHeight)/2);
int adventureTop=unlocked?menuTop+sectionHeight+groupGap:menuTop;
int communityTop=menuTop;
int gameTop=menuTop+(groupCount-1)*(sectionHeight+groupGap);
int optionsTop=gameTop+titleHeight;
add.accept(new Frame(screen.width,screen.height));
date=new Calendar(margin,3,left);
date=new Calendar(margin,3,Math.min(132,Math.max(76,screen.width/5)));
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);
add.accept(new StringWidget(menuX,top,menuWidth,9,text("pause_group_community").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,adventureTop-titleHeight,menuWidth,9,text("pause_group_adventure").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,optionsTop-titleHeight,menuWidth,9,text("pause_group_game").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
String[] sections={"identity","faction","battle","blocks","progress","story"};
add.accept(new StringWidget(menuX,adventureTop,unlocked?menuWidth:columnWidth,9,text("pause_group_adventure").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(communityX,communityTop,unlocked?menuWidth:columnWidth,9,text("pause_group_community").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,gameTop,menuWidth,9,text("pause_group_game").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
if(unlocked)sections=new String[]{"identity","faction","battle","blocks","progress","story"};
for(String target:sections) {
int y=switch(target){case "blocks"->adventureTop;case "progress"->adventureTop+step;case "story"->adventureTop+2*step;case "identity"->upperTop;case "faction"->upperTop+step;default->upperTop+2*step;};
Component label=switch(target){case "story"->StoryScreen.label("title");case "faction"->fr.koka.sanctuary.cycle.CycleService.label("faction");case "battle"->fr.koka.sanctuary.arena.Arenas.text("title");default->fr.koka.sanctuary.progression.ProgressionService.label(target);};
add.accept(Button.builder(label,b->{switch(target){case "faction"->CycleClient.open(screen,"faction");case "battle"->ArenaClient.open(screen);default->ProgressionClient.open(screen,target);}})
.bounds(menuX,y,menuWidth,navHeight).build());
boolean adventure=target.equals("blocks")||target.equals("progress")||target.equals("story");
int y=(adventure?adventureTop:communityTop)+titleHeight+step*switch(target){case "blocks","identity"->0;case "progress","faction"->1;default->2;};
add.accept(Button.builder(sectionLabel(target),b->{switch(target){case "faction"->CycleClient.open(screen,"faction");case "battle"->ArenaClient.open(screen);default->ProgressionClient.open(screen,target);}})
.bounds(adventure?menuX:communityX,y,unlocked?menuWidth:columnWidth,navHeight).build());
}
map=unlocked?new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top)):null;
if(map!=null)add.accept(map);
@@ -61,13 +70,16 @@ public final class CommunityPausePanels implements AutoCloseable {
int feedHeight=(bottom-top-gap)/2;
articles=new PauseCommunityFeed(screen,add,unlocked?rightX:margin,top,right,unlocked?feedHeight:bottom-top,"article");
notices=new PauseCommunityFeed(screen,add,rightX,unlocked?top+feedHeight+gap:top,right,unlocked?bottom-top-feedHeight-gap:bottom-top,"notice");
place(nativeButtons,add,"menu.options",menuX,optionsTop,menuWidth,optionHeight);
place(nativeButtons,add,"options.worldOptions.button",menuX,optionsTop+optionHeight+4,menuWidth,optionHeight);
place(nativeButtons,add,"menu.options",menuX,optionsTop,menuWidth,navHeight);
place(nativeButtons,add,"options.worldOptions.button",menuX,optionsTop+step,menuWidth,navHeight);
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
button.setRectangle(menuWidth,optionHeight,menuX,optionsTop+2*(optionHeight+4));button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
button.setRectangle(menuWidth,navHeight,menuX,optionsTop+2*step);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
}
updateHeader();
}
private static Component sectionLabel(String target) {
return switch(target){case "story"->StoryScreen.label("title");case "faction"->fr.koka.sanctuary.cycle.CycleService.label("faction");case "battle"->fr.koka.sanctuary.arena.Arenas.text("title");default->fr.koka.sanctuary.progression.ProgressionService.label(target);};
}
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) {
place(buttons,add,key,x,y,width,20);
@@ -117,10 +117,11 @@ public final class CompanionClient {
var buy=section.addChild(Button.builder(label("buy",config.activeCost()),b->action("buy",-1)).width(width).build());
buy.active=config.enabled()&&Minecraft.getInstance().player.experienceLevel>=config.activeCost();
}
if(record!=null&&record.unlocked())section.addChild(new StringWidget(fr.koka.sanctuary.progression.ProgressionService.label("completed"),font));
if(record!=null&&record.unlocked())AptitudeClient.acquired(section,width);
var study=contents.addChild(LinearLayout.vertical().spacing(4));
AptitudeHeading.add(study,width,font,"familiar",label("study"));
study.addChild(new MultiLineTextWidget(label("study_description"),font).setMaxWidth(width));
if(studied){AptitudeClient.acquired(study,width);return;}
var buy=study.addChild(Button.builder(label(studyInvalid?"storage_invalid":studied?"study_learned":"study_buy",CompanionStudy.COST),b->action("buy_study",-1)).width(width).build());
buy.active=!studied&&!studyInvalid&&record!=null&&config.enabled()&&Minecraft.getInstance().player.experienceLevel>=CompanionStudy.COST;
}
@@ -29,7 +29,7 @@ public final class CompanionTooltips {
public static void register(){
ItemTooltipCallback.EVENT.register((stack,context,flags,lines)->{
var type=type(stack);if(type==null||!ProgressionClient.active())return;
boolean details=CompanionClient.studied||ProgressionClient.operator;
boolean details=(CompanionClient.studied||ProgressionClient.operator);
var extra=new java.util.ArrayList<Component>();
extra.add(rarity(stack).label().copy().withStyle(color(stack)));
if(fr.koka.sanctuary.cosmetics.FamiliarEntity.burnsInDaylight(stack))
@@ -34,5 +34,6 @@ public final class FoodKnowledgeClient {
boolean learned=ready()&&knowledge.studied();
var button=group.addChild(Button.builder(label(invalid?"invalid":learned?"learned":"buy",FoodKnowledge.COST),b->{if(ClientPlayNetworking.canSend(FoodKnowledgeService.Buy.TYPE))ClientPlayNetworking.send(new FoodKnowledgeService.Buy());}).width(width).build());
button.active=ready()&&!learned&&Minecraft.getInstance().player.experienceLevel>=FoodKnowledge.COST;
if(learned)group.addChild(new MultiLineTextWidget(fr.koka.sanctuary.progression.ProgressionService.label("aptitude_permanent"),font).setMaxWidth(width));
}
}
@@ -23,7 +23,7 @@ public final class LyingClient {
if(current==3)continue; // The server ends a dive on contact, not on another key press.
int desired=m.options.keyShift.isDown() ? (current==2 ? 0 : 2) : (current!=0 ? 0 : 1);
if(desired==1&&!m.player.onGround()&&!m.player.isInWater())desired=3;
boolean learned=desired==0 || m.player.isCreative() || (desired==1 || desired==3 || ProgressionClient.progress.resting());
boolean learned=(desired==0 || m.player.isCreative() || desired==1 || desired==3 || ProgressionClient.progress.resting());
if(!learned)m.player.sendOverlayMessage(ProgressionService.label("resting_locked"));
else ClientPlayNetworking.send(new LyingService.Request(desired,ProgressionClient.progress.revision()));
}
@@ -34,6 +34,7 @@ public final class ProgressionClient {
else if (active()) gui.setScreen(section.equals("history") ? new SanctuaryAtlasScreen(parent) : new ProgressionScreen(parent,section));
}
public static void register() {
AptitudeClient.register();
IntroClient.register();
fr.koka.sanctuary.client.AccessoryClient.register();
AtlasClient.register();
@@ -92,24 +92,47 @@ public final class ProgressionScreen extends BlocodexFrame {
skills=scroll(skillContents,contentX+6,listTop,Math.max(24,listBottom-listTop),skillOffset);
int innerWidth=contentWidth-panel-32;
LinearLayout contents=LinearLayout.vertical().spacing(12);
for(String target:ProgressRecord.APTITUDES) {
var aptitude=contents.addChild(LinearLayout.vertical().spacing(4));
String icon=switch(target){case "atlas"->"worldmap";case "swimming"->"swim";case "resting"->"rest";default->target;};
AptitudeHeading.add(aptitude,innerWidth,font,icon,ProgressionService.label(target));
var description=ProgressionService.label(target+"_description");
if(target.equals("lying") || target.equals("resting"))description=description.copy().append("\n").append(LyingClient.controls(target.equals("resting")));
if(target.equals("sorting"))description=description.copy().append("\n").append(InventorySortClient.controls());
aptitude.addChild(new MultiLineTextWidget(description,font).setMaxWidth(innerWidth));
aptitude.addChild(purchaseButton(target,innerWidth));
for(var theme:List.of(
List.of("survival","swimming","resting","food_knowledge"),
List.of("inventory_crafting","sorting","catalogue"),
List.of("familiars","active_bond","familiar_study"),
List.of("exploration","atlas","names","zoom"),
List.of("crafts","enchanting","alchemy"))) {
contents.addChild(new MultiLineTextWidget(ProgressionService.label("theme_"+theme.getFirst()).copy()
.withStyle(net.minecraft.ChatFormatting.AQUA),font).setMaxWidth(innerWidth));
for(String target:theme.subList(1,theme.size())) {
switch(target) {
case "active_bond" -> CompanionClient.aptitude(contents,innerWidth,font,this);
case "familiar_study" -> {} // Rendered alongside Active Bond by the familiar module.
case "catalogue" -> RecipeClient.aptitude(contents,innerWidth,font);
case "food_knowledge" -> FoodKnowledgeClient.aptitude(contents,innerWidth,font);
case "zoom" -> ZoomClient.aptitude(contents,innerWidth,font);
default -> aptitude(contents,innerWidth,target);
}
}
}
double previousScroll=aptitudes==null ? 0 : aptitudes.getScrollAmount();
CompanionClient.aptitude(contents,innerWidth,font,this);
RecipeClient.aptitude(contents,innerWidth,font);
FoodKnowledgeClient.aptitude(contents,innerWidth,font);
ZoomClient.aptitude(contents,innerWidth,font);
aptitudes=scroll(contents,contentX+panel+16,listTop,listBottom-listTop,previousScroll);
}
private void aptitude(LinearLayout contents,int width,String target) {
var section=contents.addChild(LinearLayout.vertical().spacing(4));
String icon=switch(target){case "atlas"->"worldmap";case "swimming"->"swim";case "resting"->"rest";default->target;};
AptitudeHeading.add(section,width,font,icon,ProgressionService.label(target));
var description=ProgressionService.label(target+"_description");
if(target.equals("resting"))description=description.copy().append("\n").append(LyingClient.controls(true));
if(target.equals("sorting"))description=description.copy().append("\n").append(InventorySortClient.controls());
section.addChild(new MultiLineTextWidget(description,font).setMaxWidth(width));
boolean learned=CraftAptitudes.target(target) ? CraftAptitudes.learned(minecraft.player,target) : ProgressionClient.progress.rank(target)==1;
if(learned) {
if(AptitudePreferences.TOGGLEABLE.contains(target))AptitudeClient.toggle(section,width,target,true);
else AptitudeClient.acquired(section,width);
}
else section.addChild(purchaseButton(target,width));
}
public void waitForResponse() {pendingTicks=80;message="";purchases.forEach(button->button.active=false);}
private StringWidget textCell(LinearLayout row,int width,Component text) {
// StringWidget reports its text width in 26.3; the frame reserves a stable column.
return row.addChild(new FrameLayout(width,20)).addChild(new StringWidget(width,20,text,font)
@@ -194,13 +217,13 @@ public final class ProgressionScreen extends BlocodexFrame {
}
private Button purchaseButton(String target,int width) {
int cost=ProgressionClient.progress.cost(target,ProgressionClient.config);
int cost=CraftAptitudes.target(target) ? CraftAptitudes.COST : ProgressionClient.progress.cost(target,ProgressionClient.config);
Button button=Button.builder(cost<0 ? ProgressionService.label("completed")
: ProgressionService.label(ProgressRecord.APTITUDES.contains(target) ? "unlock_cost" : "upgrade_cost",cost),b -> {
: ProgressionService.label(ProgressRecord.APTITUDES.contains(target) || CraftAptitudes.target(target) ? "unlock_cost" : "upgrade_cost",cost),b -> {
pendingTicks=80;message="";purchases.forEach(widget -> widget.active=false);
ProgressionClient.action(target);
}).width(width).build();
button.active=ProgressionClient.progress.canRecord() && cost>=0 && minecraft.player!=null && minecraft.player.experienceLevel>=cost && pendingTicks==0;
button.active=(CraftAptitudes.target(target) || ProgressionClient.progress.canRecord()) && cost>=0 && minecraft.player!=null && minecraft.player.experienceLevel>=cost && pendingTicks==0;
purchases.add(button);
return button;
}
@@ -45,7 +45,7 @@ public final class RecipeClient {
String key="sanctuary.recipes.collection."+id.replace(':','.');
return Component.translatableWithFallback(key,id);
}
public static boolean allowed(){return ready&&!invalid&&(!active||operator||knowledge!=null&&knowledge.learned());}
public static boolean allowed(){return ready&&!invalid&&(!active||(operator||knowledge!=null&&knowledge.learned()));}
public static void reset(){ready=false;knowledge=null;active=false;invalid=false;operator=false;operatorAllowed=false;PENDING.setLength(0);revision=-1;nextPart=0;applied="";}
public static void request(String action){if(ClientPlayNetworking.canSend(RecipePayloads.Request.TYPE))ClientPlayNetworking.send(new RecipePayloads.Request(action));}
private static void accept(RecipePayloads.Snapshot p){
@@ -73,6 +73,7 @@ public final class RecipeClient {
var section=contents.addChild(LinearLayout.vertical().spacing(4));
AptitudeHeading.add(section,width,font,"recipes",label("catalogue"));
section.addChild(new MultiLineTextWidget(label("description"),font).setMaxWidth(width));
if(knowledge!=null && knowledge.learned()){AptitudeClient.acquired(section,width);return;}
var buy=section.addChild(Button.builder(label(invalid?"invalid":knowledge!=null&&knowledge.learned()?"learned":"buy",RecipeKnowledge.COST),b->request("buy")).width(width).build());
buy.active=ready&&!invalid&&knowledge!=null&&!knowledge.learned()&&xp>=RecipeKnowledge.COST;
}
@@ -339,7 +339,7 @@ public final class SanctuaryBlocodexScreen extends DiscoveryFrame {
}
private static boolean canBrowseAll() {
return ProgressionClient.operator || RecipeClient.ready && !RecipeClient.invalid && RecipeClient.knowledge!=null && RecipeClient.knowledge.learned();
return (ProgressionClient.operator || RecipeClient.ready && !RecipeClient.invalid && RecipeClient.knowledge!=null && RecipeClient.knowledge.learned());
}
private List<Filter> availableFilters() {
return fullCatalogue ? List.of(Filter.POSSESSED,Filter.ALL,Filter.SEEN,Filter.MINED,Filter.DROPPED,Filter.PLACED,Filter.CARRIED)
@@ -84,7 +84,7 @@ public final class SanctuaryMobNames {
}
public static boolean shouldDisplay(Entity entity) {
return ProgressionClient.active() && ProgressionClient.progress.names() && ProgressionClient.names.enabled() && isTargeted(entity)
return ProgressionClient.active() && ProgressionClient.progress.names() && AptitudeClient.enabled("names") && ProgressionClient.names.enabled() && isTargeted(entity)
&& entity instanceof Mob && !entity.hasCustomName();
}
@@ -33,6 +33,7 @@ public final class ZoomClient {
var group=contents.addChild(LinearLayout.vertical().spacing(4));
AptitudeHeading.add(group,width,font,"zoom",label("title"));
group.addChild(new MultiLineTextWidget(label("description",key.getTranslatedKeyMessage()),font).setMaxWidth(width));
if(learned()){AptitudeClient.acquired(group,width);return;}
var button=group.addChild(Button.builder(label(learned()?"learned":"buy",ZoomService.COST),b->{
if(ClientPlayNetworking.canSend(ZoomService.Buy.TYPE))ClientPlayNetworking.send(new ZoomService.Buy());
}).width(width).build());
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.progression.CraftAptitudes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.EnchantmentMenu;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnchantmentMenu.class)
abstract class CraftAptitudeEnchantMixin {
@Inject(method="clickMenuButton",at=@At("HEAD"),cancellable=true)
private void sanctuary$enchantAccess(Player player,int button,CallbackInfoReturnable<Boolean> ci) {
if(!CraftAptitudes.allowed(player,"enchanting"))ci.setReturnValue(false);
}
}
@@ -0,0 +1,16 @@
package fr.koka.sanctuary.mixin;
import fr.koka.sanctuary.progression.CraftAptitudes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.*;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin({EnchantmentMenu.class,BrewingStandMenu.class})
abstract class CraftAptitudeMenuMixin {
@Inject(method="stillValid",at=@At("HEAD"),cancellable=true)
private void sanctuary$craftAccess(Player player,CallbackInfoReturnable<Boolean> ci) {
if(!CraftAptitudes.allowed(player,(AbstractContainerMenu)(Object)this))ci.setReturnValue(false);
}
}
@@ -0,0 +1,23 @@
package fr.koka.sanctuary.mixin;
import com.llamalad7.mixinextras.injector.wrapoperation.*;
import fr.koka.sanctuary.progression.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.player.*;
import net.minecraft.world.inventory.AbstractContainerMenu;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@Mixin(ServerPlayer.class)
abstract class CraftAptitudeOpenMixin {
@WrapOperation(method="openMenu",at=@At(value="INVOKE",target="Lnet/minecraft/world/MenuProvider;createMenu(ILnet/minecraft/world/entity/player/Inventory;Lnet/minecraft/world/entity/player/Player;)Lnet/minecraft/world/inventory/AbstractContainerMenu;"))
private AbstractContainerMenu sanctuary$craftAccess(MenuProvider provider,int id,Inventory inventory,Player player,Operation<AbstractContainerMenu> original) {
var menu=original.call(provider,id,inventory,player);
if(menu!=null && !CraftAptitudes.allowed(player,menu)) {
((ServerPlayer)player).sendOverlayMessage(ProgressionService.label(CraftAptitudes.target(menu)+"_locked"));
menu.removed(player);return null;
}
return menu;
}
}
@@ -0,0 +1,27 @@
package fr.koka.sanctuary.progression;
import java.util.List;
/** Keep the beta.236 codec and bit order; only the first two choices remain effective. */
public record AptitudePreferences(int disabledMask) {
public static final List<String> TOGGLEABLE=List.of("names","atlas");
public static final List<String> PERMANENT=List.of("sorting","swimming","resting",
"active_bond","familiar_study","catalogue","zoom","enchanting","alchemy","food_knowledge");
public static final int MAX_MASK=2047;
public static final int ACTIVE_MASK=3;
public static final AptitudePreferences DEFAULT=new AptitudePreferences(0);
public AptitudePreferences {
if(disabledMask<0 || disabledMask>MAX_MASK)throw new IllegalArgumentException("Invalid aptitude preferences");
disabledMask&=ACTIVE_MASK;
}
private static int bit(String target) {
int index=TOGGLEABLE.indexOf(target);
if(index<0)throw new IllegalArgumentException("Unknown toggleable aptitude");
return 1<<index;
}
public boolean enabled(String target) {return PERMANENT.contains(target) || (disabledMask&bit(target))==0;}
public AptitudePreferences with(String target,boolean enabled) {
int bit=bit(target);
return new AptitudePreferences(enabled ? disabledMask&~bit : disabledMask|bit);
}
}
@@ -0,0 +1,80 @@
package fr.koka.sanctuary.progression;
import com.mojang.serialization.Codec;
import fr.koka.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
/** Personal, server-authoritative choices; never removes or rewrites a purchase. */
public final class AptitudeService {
public static final AttachmentType<Integer> PREFERENCES=AttachmentRegistry.create(SanctuaryMod.id("aptitude_preferences"),
b->b.persistent(Codec.intRange(0,AptitudePreferences.MAX_MASK)).copyOnDeath());
private AptitudeService() {}
public record Capability() implements CustomPacketPayload {
public static final Type<Capability> TYPE=new Type<>(SanctuaryMod.id("aptitude_preferences_v1"));
public static final StreamCodec<FriendlyByteBuf,Capability> CODEC=StreamCodec.unit(new Capability());
public Type<Capability> type(){return TYPE;}
}
public record PermanentCapability() implements CustomPacketPayload {
public static final Type<PermanentCapability> TYPE=new Type<>(SanctuaryMod.id("aptitude_permanent_v1"));
public static final StreamCodec<FriendlyByteBuf,PermanentCapability> CODEC=StreamCodec.unit(new PermanentCapability());
public Type<PermanentCapability> type(){return TYPE;}
}
public record Set(String target,boolean enabled,int expectedMask) implements CustomPacketPayload {
public static final Type<Set> TYPE=new Type<>(SanctuaryMod.id("aptitude_set_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,Set> CODEC=CustomPacketPayload.codec(
(p,b)->{b.writeUtf(p.target,32);b.writeBoolean(p.enabled);b.writeVarInt(p.expectedMask);},
b->new Set(b.readUtf(32),b.readBoolean(),b.readVarInt()));
public Type<Set> type(){return TYPE;}
}
public record State(int entity,int disabledMask,boolean enchanting,boolean alchemy) implements CustomPacketPayload {
public static final Type<State> TYPE=new Type<>(SanctuaryMod.id("aptitude_state_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf,State> CODEC=CustomPacketPayload.codec(
(p,b)->{b.writeVarInt(p.entity);b.writeVarInt(p.disabledMask);b.writeBoolean(p.enchanting);b.writeBoolean(p.alchemy);},
b->new State(b.readVarInt(),b.readVarInt(),b.readBoolean(),b.readBoolean()));
public Type<State> type(){return TYPE;}
}
public static AptitudePreferences preferences(Player p) {return new AptitudePreferences(p.getAttachedOrElse(PREFERENCES,0));}
public static boolean enabled(Player p,String target) {return preferences(p).enabled(target);}
public static boolean owned(ServerPlayer p,String target) {
var progress=ProgressEffects.get(p);
if(progress==null)return false;
return AptitudePreferences.TOGGLEABLE.contains(target) && progress.rank(target)==1;
}
public static boolean set(ServerPlayer p,Set request) {
var session=ProgressionService.session(p.level().getServer());
if(session==null || !session.healthy || !p.isAlive() || p.isSpectator()
|| !AptitudePreferences.TOGGLEABLE.contains(request.target()))return false;
var old=preferences(p);
if(request.expectedMask()!=old.disabledMask())return false;
try {if(!owned(p,request.target()))return false;}
catch(RuntimeException invalid){return false;}
var next=old.with(request.target(),request.enabled());
if(!next.equals(old))p.setAttached(PREFERENCES,next.disabledMask());
return true;
}
public static void send(ServerPlayer p) {
// Normalize legacy preferences on sync without altering purchases or progression.
int mask=preferences(p).disabledMask();
if(p.getAttachedOrElse(PREFERENCES,0)!=mask)p.setAttached(PREFERENCES,mask);
if(ServerPlayNetworking.canSend(p,State.TYPE))ServerPlayNetworking.send(p,new State(p.getId(),preferences(p).disabledMask(),CraftAptitudes.learned(p,"enchanting"),CraftAptitudes.learned(p,"alchemy")));
}
public static void register() {
java.util.Objects.requireNonNull(CraftAptitudes.ENCHANTING);
java.util.Objects.requireNonNull(CraftAptitudes.ALCHEMY);
PayloadTypeRegistry.clientboundConfiguration().register(Capability.TYPE,Capability.CODEC);
PayloadTypeRegistry.clientboundConfiguration().register(PermanentCapability.TYPE,PermanentCapability.CODEC);
PayloadTypeRegistry.serverboundPlay().register(Set.TYPE,Set.CODEC);
PayloadTypeRegistry.clientboundPlay().register(State.TYPE,State.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Set.TYPE,(request,c)->c.server().execute(()->{
boolean accepted=set(c.player(),request);
ProgressionService.send(c.player(),accepted ? "" : "aptitude_refused");
}));
}
}
@@ -0,0 +1,41 @@
package fr.koka.sanctuary.progression;
import com.mojang.serialization.Codec;
import fr.koka.sanctuary.SanctuaryMod;
import net.fabricmc.fabric.api.attachment.v1.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.*;
/** Access to personal workstations; shared brewing processes keep vanilla behavior. */
public final class CraftAptitudes {
public static final int COST=4;
public static final AttachmentType<Boolean> ENCHANTING=AttachmentRegistry.create(SanctuaryMod.id("enchanting_learned"),
b->b.persistent(Codec.BOOL).copyOnDeath());
public static final AttachmentType<Boolean> ALCHEMY=AttachmentRegistry.create(SanctuaryMod.id("alchemy_learned"),
b->b.persistent(Codec.BOOL).copyOnDeath());
private CraftAptitudes() {}
public static boolean target(String target){return target.equals("enchanting") || target.equals("alchemy");}
private static AttachmentType<Boolean> attachment(String target) {
return switch(target){case "enchanting"->ENCHANTING;case "alchemy"->ALCHEMY;default->throw new IllegalArgumentException("Unknown craft aptitude");};
}
public static boolean learned(Player p,String target){return p.getAttachedOrElse(attachment(target),false);}
public static String target(AbstractContainerMenu menu){return menu instanceof EnchantmentMenu ? "enchanting" : menu instanceof BrewingStandMenu ? "alchemy" : "";}
public static boolean allowed(Player p,String target) {
if(ProgressEffects.get(p)==null)return true;
if(p instanceof ServerPlayer server) {
var session=ProgressionService.session(server.level().getServer());
if(session==null || !session.healthy)return false;
}
return learned(p,target);
}
public static boolean allowed(Player p,AbstractContainerMenu menu) {
String target=target(menu);return target.isEmpty() || allowed(p,target);
}
public static boolean buy(ServerPlayer p,String target,int revision) {
var session=ProgressionService.session(p.level().getServer());var progress=ProgressEffects.get(p);
if(!target(target) || session==null || !session.healthy || progress==null || progress.revision()!=revision
|| !p.isAlive() || p.isSpectator() || learned(p,target) || p.experienceLevel<COST)return false;
p.giveExperienceLevels(-COST);p.setAttached(attachment(target),true);return true;
}
}
@@ -26,7 +26,7 @@ public final class ProgressEffects {
}
public static boolean canSwim(Player player) {
var progress=get(player);
return progress==null || progress.swimming() || player.isCreative() || player.isSpectator();
return progress==null || (progress.swimming() || player.isCreative() || player.isSpectator());
}
public static void bind(Player player, ProgressRecord progress) {
PLAYERS.put(player, progress); FOOD.put(player.getFoodData(), progress);
@@ -67,6 +67,7 @@ public final class ProgressionService {
PayloadTypeRegistry.clientboundConfiguration().register(AccessPayloads.Status.TYPE,AccessPayloads.Status.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Check.TYPE,AccessPayloads.Check.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AccessPayloads.Admit.TYPE,AccessPayloads.Admit.CODEC);
AptitudeService.register();
SanctuarySitting.register();
fr.koka.sanctuary.inventory.HotbarService.register();
fr.koka.sanctuary.inventory.InventorySortService.register();
@@ -116,6 +117,10 @@ public final class ProgressionService {
if (!ServerConfigurationNetworking.canSend(handler,TutorialPayloads.Capability.TYPE)) {
handler.disconnect(TutorialService.label("version_required"));return;
}
if (!ServerConfigurationNetworking.canSend(handler,AptitudeService.Capability.TYPE)
|| !ServerConfigurationNetworking.canSend(handler,AptitudeService.PermanentCapability.TYPE)) {
handler.disconnect(label("aptitude_version_required"));return;
}
if (!ServerConfigurationNetworking.canSend(handler,ProgressionPayloads.SwimmingCapability.TYPE)) {
handler.disconnect(label("swimming_version_required"));return;
}
@@ -522,6 +527,10 @@ public final class ProgressionService {
boolean removed=refund(player,action.target().substring(7),action.revision());
send(player,removed ? "refunded" : "refund_refused");return;
}
if (CraftAptitudes.target(action.target())) {
boolean bought=CraftAptitudes.buy(player,action.target(),action.revision());
send(player,bought ? "purchased" : "purchase_refused");return;
}
if (!ProgressEffects.get(player).canRecord()) {send(player,"history_full");return;}
boolean bought=purchase(player,action.target(),action.revision());
send(player,bought ? "purchased" : "purchase_refused");
@@ -536,6 +545,7 @@ public final class ProgressionService {
// The solo pause menu processes networking without ticking the player's XP synchronizer.
player.connection.send(new net.minecraft.network.protocol.game.ClientboundSetExperiencePacket(
player.experienceProgress,player.totalExperience,player.experienceLevel));
AptitudeService.send(player);
ServerPlayNetworking.send(player,new ProgressionPayloads.Snapshot(
JSON.toJson(session.inhabitants.get(player.getUUID())),player.getAttachedOrThrow(RECORD),JSON.toJson(session.config),message,canOperate(player)));
player.inventoryMenu.broadcastFullState();
@@ -569,7 +569,7 @@
"sanctuary.progression.sorting_version_required": "Inventory sorting requires matching Sanctuary beta.014 or later versions on the client and server.",
"screen.sanctuary.inventory.sort_key": "Sort hovered inventory area",
"screen.sanctuary.inventory.sort_hint": "%s: sort hovered storage; over the hotbar, sort only the hotbar.",
"screen.sanctuary.inventory.sort_locked": "Unlock Inventory sorting in the Blocodex Aptitudes panel.",
"screen.sanctuary.inventory.sort_locked": "Unlock or enable Inventory sorting in Progression.",
"screen.sanctuary.inventory.sort_refused": "Sorting refused: check the menu and empty your cursor, then try again.",
"sanctuary.tutorial.movement": "Move and look around",
"sanctuary.tutorial.find_tree": "Find a tree",
@@ -589,7 +589,7 @@
"sanctuary.progression.resting": "Rest",
"sanctuary.progression.resting_description": "Rest in the sleeping pose to regain half a heart every 10 s, using half a food icon. Moving or taking damage interrupts rest.",
"sanctuary.progression.resting_controls": "Shift + %s to rest (remappable key)",
"sanctuary.progression.resting_locked": "Unlock Rest in the Blocodex aptitudes.",
"sanctuary.progression.resting_locked": "Unlock or enable Rest in Progression.",
"sanctuary.progression.next_cost_short": "%s lv.",
"sanctuary.progression.rank_max_short": "Max.",
"sanctuary.progression.skill_bar_hint": "The bar shows acquired tiers; the number is the next tier’s cost in XP levels.",
@@ -802,7 +802,7 @@
"sanctuary.companion.unavailable": "Familiar powers are unavailable.",
"sanctuary.companion.storage_invalid": "The bond data cannot be read. It has been preserved.",
"sanctuary.companion.refused": "This action is not allowed here.",
"sanctuary.companion.locked": "Unlock Active bond first.",
"sanctuary.companion.locked": "Unlock or enable Active bond in Progression.",
"sanctuary.companion.no_familiar": "Equip a familiar egg.",
"sanctuary.companion.attuning": "This familiar is still attuning.",
"sanctuary.companion.cooldown": "The power is still cooling down.",
@@ -2618,5 +2618,26 @@
"biome.sanctuary.diagonal_swamp229": "Upland Swamp",
"biome.sanctuary.diagonal_pale229": "Sheltered Pale Garden",
"biome.sanctuary.diagonal_sulfur229": "Sulfur Cavities",
"biome.sanctuary.diagonal_pale230": "Lower Pale Garden"
"biome.sanctuary.diagonal_pale230": "Lower Pale Garden",
"sanctuary.progression.aptitude_enabled": "Enabled",
"sanctuary.progression.aptitude_disabled": "Disabled",
"sanctuary.progression.aptitude_toggle_hint": "Changing this choice is free and keeps your purchase.",
"sanctuary.progression.aptitude_permanent": "Permanent knowledge",
"sanctuary.progression.aptitude_refused": "The choice was not applied. Try again.",
"sanctuary.progression.aptitude_version_required": "Permanent aptitudes require Sanctuary beta.237 or later on both client and server.",
"sanctuary.progression.theme_survival": "Survival",
"sanctuary.progression.theme_inventory_crafting": "Inventory and crafting",
"sanctuary.progression.theme_familiars": "Familiars",
"sanctuary.progression.theme_exploration": "Exploration",
"sanctuary.progression.theme_crafts": "Arts and crafts",
"sanctuary.progression.enchanting": "Enchanting",
"sanctuary.progression.enchanting_description": "Use an enchanting table to enchant items.",
"sanctuary.progression.enchanting_locked": "Unlock and enable Enchanting in Progression to use this table.",
"sanctuary.progression.alchemy": "Alchemy",
"sanctuary.progression.alchemy_description": "Use a brewing stand to brew potions.",
"sanctuary.progression.alchemy_locked": "Unlock and enable Alchemy in Progression to use this brewing stand.",
"sanctuary.progression.aptitude_disable_hint": "Click to disable.",
"sanctuary.progression.aptitude_enable_hint": "Click to enable.",
"sanctuary.progression.aptitude_acquired": "Acquired",
"sanctuary.progression.aptitude_acquired_hint": "This aptitude remains permanently available after purchase."
}
@@ -569,7 +569,7 @@
"sanctuary.progression.sorting_version_required": "Le rangement nécessite la même version de Sanctuary beta.014 ou ultérieure sur le client et le serveur.",
"screen.sanctuary.inventory.sort_key": "Ranger la zone d’inventaire visée",
"screen.sanctuary.inventory.sort_hint": "%s : ranger le stockage visé ; sur la hotbar, ranger seulement la hotbar.",
"screen.sanctuary.inventory.sort_locked": "Débloque Rangement dans les Aptitudes du Blocodex.",
"screen.sanctuary.inventory.sort_locked": "Débloquez ou activez le tri d’inventaire dans Progression.",
"screen.sanctuary.inventory.sort_refused": "Rangement refusé : vérifie le menu et libère le curseur, puis réessaie.",
"sanctuary.tutorial.movement": "Se déplacer et regarder autour de soi",
"sanctuary.tutorial.find_tree": "Trouver un arbre",
@@ -589,7 +589,7 @@
"sanctuary.progression.resting": "Se reposer",
"sanctuary.progression.resting_description": "Repose-toi dans la posture de sommeil pour récupérer un demi-cœur toutes les 10 s, contre une demi-barre de faim. Bouger ou subir des dégâts interrompt le repos.",
"sanctuary.progression.resting_controls": "Maj + %s pour se reposer (touche configurable)",
"sanctuary.progression.resting_locked": "Débloque Se reposer dans les aptitudes du Blocodex.",
"sanctuary.progression.resting_locked": "Débloquez ou activez Se reposer dans Progression.",
"sanctuary.progression.next_cost_short": "%s niv.",
"sanctuary.progression.rank_max_short": "Max.",
"sanctuary.progression.skill_bar_hint": "La barre indique les paliers acquis ; le nombre est le prix du prochain palier en niveaux d’XP.",
@@ -802,7 +802,7 @@
"sanctuary.companion.unavailable": "Les pouvoirs de familiers sont indisponibles.",
"sanctuary.companion.storage_invalid": "Les données du lien ne peuvent pas être lues. Elles ont été conservées.",
"sanctuary.companion.refused": "Ce geste n’est pas autorisé ici.",
"sanctuary.companion.locked": "Débloque d’abord l’aptitude Lien actif.",
"sanctuary.companion.locked": "Débloquez ou activez Lien actif dans Progression.",
"sanctuary.companion.no_familiar": "Équipe un œuf de familier.",
"sanctuary.companion.attuning": "Le lien avec ce familier se forme encore.",
"sanctuary.companion.cooldown": "Le pouvoir est encore en recharge.",
@@ -2618,5 +2618,26 @@
"biome.sanctuary.diagonal_swamp229": "Marais perché",
"biome.sanctuary.diagonal_pale229": "Jardin pâle abrité",
"biome.sanctuary.diagonal_sulfur229": "Cavités de soufre",
"biome.sanctuary.diagonal_pale230": "Jardin pâle inférieur"
"biome.sanctuary.diagonal_pale230": "Jardin pâle inférieur",
"sanctuary.progression.aptitude_enabled": "Activée",
"sanctuary.progression.aptitude_disabled": "Désactivée",
"sanctuary.progression.aptitude_toggle_hint": "Changer ce choix est gratuit et conserve votre achat.",
"sanctuary.progression.aptitude_permanent": "Connaissance permanente",
"sanctuary.progression.aptitude_refused": "Le choix n’a pas été appliqué. Réessayez.",
"sanctuary.progression.aptitude_version_required": "Les aptitudes permanentes nécessitent Sanctuary beta.237 ou plus récent côté client et serveur.",
"sanctuary.progression.theme_survival": "Survie",
"sanctuary.progression.theme_inventory_crafting": "Inventaire et fabrication",
"sanctuary.progression.theme_familiars": "Familiers",
"sanctuary.progression.theme_exploration": "Exploration",
"sanctuary.progression.theme_crafts": "Arts et métiers",
"sanctuary.progression.enchanting": "Enchantement",
"sanctuary.progression.enchanting_description": "Utiliser une table d’enchantement pour enchanter des objets.",
"sanctuary.progression.enchanting_locked": "Débloquez et activez Enchantement dans Progression pour utiliser cette table.",
"sanctuary.progression.alchemy": "Alchimie",
"sanctuary.progression.alchemy_description": "Utiliser un alambic pour préparer des potions.",
"sanctuary.progression.alchemy_locked": "Débloquez et activez Alchimie dans Progression pour utiliser cet alambic.",
"sanctuary.progression.aptitude_disable_hint": "Cliquer pour désactiver.",
"sanctuary.progression.aptitude_enable_hint": "Cliquer pour activer.",
"sanctuary.progression.aptitude_acquired": "Acquise",
"sanctuary.progression.aptitude_acquired_hint": "Cette aptitude est acquise définitivement après achat."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

@@ -3,6 +3,9 @@
"package": "fr.koka.sanctuary.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"CraftAptitudeOpenMixin",
"CraftAptitudeMenuMixin",
"CraftAptitudeEnchantMixin",
"IslandChestLootMixin",
"BadlandsBands211Accessor",
"WestMonument217Accessor",
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.234"
version = "beta.237"
pack-format = "packwiz:1.1.0"
[index]