feat: add native Blocodex and material census for alpha23
This commit is contained in:
@@ -20,6 +20,35 @@ publiée** : le placement des structures redevient sélectif. Les palettes,
|
|||||||
intérieurs et filons de l’alpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md)
|
intérieurs et filons de l’alpha.21 sont conservés. Voir [les essais22](docs/testing-alpha22.md)
|
||||||
et [la distribution](docs/packwiz.md).
|
et [la distribution](docs/packwiz.md).
|
||||||
|
|
||||||
|
## Alpha.23 — Blocodex et socle commun de données, livraison locale
|
||||||
|
|
||||||
|
Le Blocodex fait partie de Sanctuary et s’ouvre avec **B**, touche modifiable
|
||||||
|
dans les contrôles. Son catalogue permet de rechercher et filtrer les blocs
|
||||||
|
inconnus, vus, minés, déjà possédés, jetés ou posés. Le stock actuellement porté
|
||||||
|
est affiché séparément de la possession passée. Aucun objet ni permission
|
||||||
|
opérateur n’est nécessaire.
|
||||||
|
|
||||||
|
La mémoire est personnelle et conservée côté serveur. Les compteurs de minage
|
||||||
|
et de jet viennent des statistiques Minecraft ; les observations et poses
|
||||||
|
commencent avec cette version. Les sauvegardes précédentes gardent leur terrain
|
||||||
|
et leur génération.
|
||||||
|
|
||||||
|
Le socle commun ajoute la distribution du terrain chargé par dimension et
|
||||||
|
chunk, ainsi que les quantités d’items dans les inventaires connectés, les
|
||||||
|
conteneurs chargés et au sol. La couverture et l’actualité accompagnent chaque
|
||||||
|
relevé ; les coffres dont le butin n’est pas encore généré restent intacts.
|
||||||
|
Les opérateurs disposent de `/sanctuary census`. Palettes de structures,
|
||||||
|
expansions et shop pourront réutiliser ces données côté serveur.
|
||||||
|
|
||||||
|
Des relevés par jour UTC des activités de minage, pose, ramassage, jet et
|
||||||
|
fabrication conservent des périodes de
|
||||||
|
l’histoire du serveur. Le [contrat Blocodex](docs/blocodex.md) précise les APIs,
|
||||||
|
la persistance et les exclusions. Shop, ballast, Backrooms et leurs règles de
|
||||||
|
progression restent à construire ; la direction narrative est conservée dans
|
||||||
|
la [cosmologie](docs/cosmologie.md). L’alpha.23 n’est pas encore publiée sur le
|
||||||
|
canal packwiz. `check build assemblePack`, les 24 GameTests natifs et
|
||||||
|
le client graphique connecté passent ; voir [les essais alpha.23](docs/testing-alpha23.md).
|
||||||
|
|
||||||
La suite est en [conception](docs/structures-conception.md) :
|
La suite est en [conception](docs/structures-conception.md) :
|
||||||
départ naturel, quelques lieux isolés, fonctions reproductibles et donjon majeur
|
départ naturel, quelques lieux isolés, fonctions reproductibles et donjon majeur
|
||||||
à définir. Ce cahier précède la reprise du code ; l’alpha.22 reste la livraison
|
à définir. Ce cahier précède la reprise du code ; l’alpha.22 reste la livraison
|
||||||
@@ -489,7 +518,7 @@ sera une mise à jour explicite, avec vérification des API et des sauvegardes.
|
|||||||
| Fabric API | 0.160.0+26.3 |
|
| Fabric API | 0.160.0+26.3 |
|
||||||
| Fabric Loom | 1.17.20 |
|
| Fabric Loom | 1.17.20 |
|
||||||
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
|
||||||
| Sanctuary / pack | 0.1.0-alpha.21 |
|
| Sanctuary / pack | 0.1.0-alpha.23 |
|
||||||
|
|
||||||
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
|
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
|
||||||
uniquement la bibliothèque standard et repère aussi une installation Python
|
uniquement la bibliothèque standard et repère aussi une installation Python
|
||||||
@@ -518,7 +547,7 @@ décrits dans [Validation](docs/testing.md).
|
|||||||
|
|
||||||
Résultats :
|
Résultats :
|
||||||
|
|
||||||
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.21.jar` : mod à installer avec
|
- `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.23.jar` : mod à installer avec
|
||||||
Fabric API sur la version Minecraft indiquée.
|
Fabric API sur la version Minecraft indiquée.
|
||||||
- `build/packwiz/` : pack de développement complet, avec le mod construit et
|
- `build/packwiz/` : pack de développement complet, avec le mod construit et
|
||||||
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
|
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
|
||||||
|
|||||||
+42
-1
@@ -860,6 +860,47 @@ Ce ticket documentaire n’incrémente pas les versions et ne clôture pas les
|
|||||||
décisions encore ouvertes. Les critères de la prochaine implémentation seront
|
décisions encore ouvertes. Les critères de la prochaine implémentation seront
|
||||||
établis à partir du cahier validé.
|
établis à partir du cahier validé.
|
||||||
|
|
||||||
|
### META-01 — Blocodex natif et socle commun de données — alpha.23
|
||||||
|
|
||||||
|
**Branche :** `codex/blocodex-natif`.
|
||||||
|
|
||||||
|
**Demande :** intégrer à Sanctuary une connaissance des blocs inconnus, vus,
|
||||||
|
minés, possédés, jetés et posés, puis la distribution du terrain et des stocks
|
||||||
|
accessibles. Les palettes, expansions et shop futurs doivent consulter un socle
|
||||||
|
commun, avec des périodes d’activité utilisables pour l’histoire du serveur.
|
||||||
|
|
||||||
|
- Mémoire personnelle par UUID et sauvegarde, commune aux dimensions ;
|
||||||
|
états indépendants et stock porté distinct de la possession historique.
|
||||||
|
- Minage et jet issus des statistiques vanilla ; suivi serveur des observations
|
||||||
|
et poses réellement réussies. Pas d’inférence des poses depuis `used`.
|
||||||
|
- Catalogue natif accessible sans item ni droit opérateur, recherche, filtres,
|
||||||
|
pagination, aide FR/EN et touche configurable.
|
||||||
|
- Service de lecture et notifications pour les futurs consommateurs ;
|
||||||
|
aucune récompense ni restriction de recette ajoutée par ce ticket.
|
||||||
|
- Sélection de palettes pour les futures structures : critères de connaissance
|
||||||
|
combinables et familles de matériaux définies par tags, dont une base naturelle.
|
||||||
|
- Terrain chargé par dimension et chunk : comptage progressif de 16 sections
|
||||||
|
non vides par tick, couverture et actualité explicites, sans charger ni
|
||||||
|
régénérer de chunk absent.
|
||||||
|
- Stocks courants séparés entre joueurs connectés, conteneurs blocs,
|
||||||
|
conteneurs entités et objets au sol. Tables de butin laissées intactes ;
|
||||||
|
stocks hors couverture et contenus imbriqués explicitement exclus.
|
||||||
|
- API commune `WorldCensusService` et diagnostics opérateur `/sanctuary census`.
|
||||||
|
Le shop et les règles de consommation ou d’expansion ne sont pas activés.
|
||||||
|
- Collecte datée : unités minées, posées, ramassées, jetées et
|
||||||
|
fabriquées par jour UTC, avec persistance séparée et sauvegarde tous les
|
||||||
|
600 ticks. Ni reconstruction rétroactive ni journal transactionnel de ballast.
|
||||||
|
- Contrat additif de persistance v1 documenté dans [Blocodex](blocodex.md),
|
||||||
|
conservation des identifiants et refus d’écraser une mémoire invalide.
|
||||||
|
- Vérification des actions natives, de l’isolement des joueurs, de la persistance,
|
||||||
|
du rendu et des messages réseau ; contrôle des catégories de stocks, des tables
|
||||||
|
de butin et de la couverture du terrain ; `check build assemblePack`.
|
||||||
|
|
||||||
|
**État : Blocodex, recensements et relevés datés implémentés et vérifiés localement.
|
||||||
|
Publication demandée, préparation des artefacts en cours.** `check build assemblePack`, les 24 GameTests natifs
|
||||||
|
et le client graphique avec connexion réelle passent. Voir
|
||||||
|
[les preuves alpha.23](testing-alpha23.md).
|
||||||
|
|
||||||
## Réserve de thèmes futurs
|
## Réserve de thèmes futurs
|
||||||
|
|
||||||
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
|
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
|
||||||
@@ -872,7 +913,7 @@ Ces thèmes servent à retrouver la vision, pas à demander leur implémentation
|
|||||||
| Économie | gemmes, mailbox, shop, offres horaires, bourse du navet (achat à la loterie du dimanche, revente au shop pendant la semaine), black market, catalogue, coffre-fort et drill |
|
| Économie | gemmes, mailbox, shop, offres horaires, bourse du navet (achat à la loterie du dimanche, revente au shop pendant la semaine), black market, catalogue, coffre-fort et drill |
|
||||||
| Groupes et métiers | couleurs, équipes temporaires, factions et slots liés aux prestiges, cloches et bannières, villageois et copper golems |
|
| Groupes et métiers | couleurs, équipes temporaires, factions et slots liés aux prestiges, cloches et bannières, villageois et copper golems |
|
||||||
| Production et construction | convoyeurs, stockage, terminaux, ordinateur 8 bits, vein mining/building, plans et prefabs |
|
| Production et construction | convoyeurs, stockage, terminaux, ordinateur 8 bits, vein mining/building, plans et prefabs |
|
||||||
| Dimensions | cavernes, Alpha, Backrooms, indoors, salles secrètes et récupération des objets perdus |
|
| Dimensions et espaces | cavernes, Alpha ; Indoors artificiels intentionnels et référencés ; Backrooms orphelines liées à l’histoire économique, avec hébergement technique et récupération des objets perdus à articuler selon la [cosmologie](cosmologie.md) |
|
||||||
| Faune et combats | zombies, fantômes, baleine, creepers, poules rares, Mooblooms, noms, armes et explosifs |
|
| Faune et combats | zombies, fantômes, baleine, creepers, poules rares, Mooblooms, noms, armes et explosifs |
|
||||||
| Mobilité | waystones, téléporteurs, ziplines, grappin, aéronefs, Magic Carpet et interactions physiques |
|
| Mobilité | waystones, téléporteurs, ziplines, grappin, aéronefs, Magic Carpet et interactions physiques |
|
||||||
| Temps et histoire | temps réel, calendrier, événements, loterie, étoiles, constellations, cube et sept boules |
|
| Temps et histoire | temps réel, calendrier, événements, loterie, étoiles, constellations, cube et sept boules |
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# Blocodex natif — connaissances, matière et stocks
|
||||||
|
|
||||||
|
Ticket META-01, branche `codex/blocodex-natif`, cible Minecraft **26.3-pre-2**.
|
||||||
|
Le Blocodex appartient au mod Sanctuary. Il est disponible sans appareil,
|
||||||
|
recette ni déblocage. La mémoire personnelle, le recensement du terrain et
|
||||||
|
celui des stocks forment un socle commun aux futures palettes de structures,
|
||||||
|
règles d’expansion et règles du shop. Les relevés datés ajoutent la temporalité
|
||||||
|
nécessaire à une future lecture de l’histoire économique du serveur.
|
||||||
|
|
||||||
|
## Contrat des informations
|
||||||
|
|
||||||
|
La mémoire est personnelle : une identité de joueur (UUID), dans une sauvegarde
|
||||||
|
de serveur, partagée entre ses dimensions. Les indicateurs sont indépendants.
|
||||||
|
|
||||||
|
| Information | Sens |
|
||||||
|
| --- | --- |
|
||||||
|
| Inconnu | Aucune observation, possession, pose ni statistique de minage ou de jet. C'est un état calculé, pas une étape enregistrée. |
|
||||||
|
| Vu | Le premier bloc rencontré par le regard du joueur, à au plus 32 blocs, observé côté serveur tous les 5 ticks. Les obstacles arrêtent le rayon ; les chunks absents ne sont pas chargés. Ce relevé ne couvre pas chaque pixel du champ de vision. |
|
||||||
|
| Miné | Compteur vanilla `mined` du bloc. Ses règles sont conservées : notamment pas de minage créatif, ni de minage sans l'outil requis. |
|
||||||
|
| Déjà possédé | Le bloc, sous forme d'item, a été présent dans l'inventaire personnel ou sur le curseur d'un menu. Les statistiques vanilla de ramassage et de jet prouvent aussi une possession passée. |
|
||||||
|
| Jeté | Compteur vanilla `dropped` de l'item du bloc. Il compte les unités volontairement jetées, pas les objets éjectés à la mort ni le butin produit par le bloc cassé. |
|
||||||
|
| Posé | Nombre de placements réussis par le joueur avec un item de bloc, y compris en créatif. Une tentative refusée, une interaction ou un placement par commande ne compte pas. |
|
||||||
|
| En inventaire | Quantité actuellement dans l'inventaire personnel, équipement et curseur compris. Les coffres ouverts, ender chests et contenus imbriqués des boîtes ne sont pas inclus. |
|
||||||
|
|
||||||
|
Les blocs sont identifiés par leur identifiant de registre ; les propriétés
|
||||||
|
d'état (orientation, humidité, etc.) ne créent pas une nouvelle fiche. Deux
|
||||||
|
identifiants restent distincts : une torche murale peut être vue et posée tandis
|
||||||
|
que l'item possédé correspond à la torche debout. Les noms des blocs inconnus
|
||||||
|
restent consultables, comme un catalogue ; cette version ne décide pas d'une
|
||||||
|
politique de secrets ou de verrouillage des recettes.
|
||||||
|
|
||||||
|
L'inventaire est observé chaque tick serveur, après les transactions de menus
|
||||||
|
et avant la consommation d'un item placé. Les statistiques de ramassage/jet
|
||||||
|
complètent ce relevé. Une mutation d'inventaire entièrement réalisée entre ces
|
||||||
|
points par un autre mod ne peut pas être reconstruite après coup.
|
||||||
|
Les spectateurs ne découvrent pas de blocs par le regard.
|
||||||
|
|
||||||
|
## Persistance et arrivée dans une sauvegarde antérieure
|
||||||
|
|
||||||
|
Contrat additif v1 : fichiers `data/sanctuary-blocodex/<uuid>.json`, enveloppe
|
||||||
|
avec `schema: 1` et UUID, puis observations, possessions et poses par identifiant.
|
||||||
|
Les statistiques vanilla restent dans leurs propres fichiers et sont seulement
|
||||||
|
lues. Aucune conversion du terrain, des paramètres de génération, des chunks,
|
||||||
|
du journal d'expansion ou des statistiques existantes n'est effectuée.
|
||||||
|
|
||||||
|
Sans fichier Blocodex, les observations et poses commencent vides. Le minage,
|
||||||
|
le jet et les preuves de possession sont disponibles depuis les statistiques
|
||||||
|
vanilla déjà présentes. On n'invente pas les observations ni les poses passées
|
||||||
|
à partir de `used`, qui compte aussi d'autres usages.
|
||||||
|
|
||||||
|
Les changements sont sauvegardés tous les 600 ticks, lors de la sauvegarde du
|
||||||
|
serveur, à la déconnexion et à l'arrêt. Un crash peut perdre les dernières
|
||||||
|
observations ou poses non sauvegardées. Un fichier invalide ou d'une version
|
||||||
|
inconnue désactive la mémoire concernée et n'est jamais remplacé silencieusement.
|
||||||
|
Les identifiants de mods absents sont conservés sur disque. Les anciennes
|
||||||
|
versions de Sanctuary ignorent ce répertoire additionnel ; les poses réalisées
|
||||||
|
pendant un retour à une ancienne version ne peuvent pas être récupérées.
|
||||||
|
|
||||||
|
## Réutilisation
|
||||||
|
|
||||||
|
`BlockKnowledgeService.knowledge(player, block)` et `snapshot(player)` renvoient
|
||||||
|
des valeurs immuables calculées côté serveur. Le service expose aussi sa santé ;
|
||||||
|
un consommateur doit refuser une condition de progression si la mémoire est
|
||||||
|
indisponible, plutôt que d'interpréter une panne comme une découverte vierge.
|
||||||
|
`BlockKnowledgeEvents.CHANGED` permet de réagir aux observations, possessions,
|
||||||
|
poses et changements de statistiques. Les écouteurs s'exécutent sur le thread
|
||||||
|
serveur ; ils ne constituent pas un journal transactionnel de récompenses.
|
||||||
|
|
||||||
|
Cette livraison ne verrouille aucune recette et n'accorde aucune récompense.
|
||||||
|
Palettes disponibles, recherches, progression et structures pourront ensuite
|
||||||
|
définir leurs propres conditions en utilisant cette API commune.
|
||||||
|
|
||||||
|
### Palettes de structures
|
||||||
|
|
||||||
|
`BlockKnowledgeService.palette(player, condition, tag)` croise les connaissances
|
||||||
|
du joueur avec une famille de matériaux. Les critères `BlockKnowledgeFacet`
|
||||||
|
sont combinables avec `and` et `or` : vus **ou** déjà possédés, minés **et**
|
||||||
|
actuellement portés, etc. Le résultat immuable garde les identifiants triés et
|
||||||
|
les quantités réellement portées.
|
||||||
|
|
||||||
|
```java
|
||||||
|
var candidates = BlockKnowledgeService.palette(player,
|
||||||
|
BlockKnowledgeFacet.SEEN.or(BlockKnowledgeFacet.POSSESSED),
|
||||||
|
BlockKnowledgeService.NATURAL_STRUCTURE_MATERIALS);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le tag `sanctuary:structure_palette/natural` fournit une première famille de
|
||||||
|
pierres, terres, bois, feuilles et autres matériaux naturels, modifiable par
|
||||||
|
datapack. D'autres tags pourront définir des palettes minérales, végétales ou
|
||||||
|
propres à un biome. Le choix des couleurs, les contraintes physiques des blocs,
|
||||||
|
les quantités nécessaires et la construction de la structure appartiendront au
|
||||||
|
moteur de structures : une sélection de palette ne réserve aucun stock et ne
|
||||||
|
place aucun bloc.
|
||||||
|
|
||||||
|
## Recensement du terrain chargé
|
||||||
|
|
||||||
|
`TerrainCensus.snapshot(server)` expose une distribution des blocs par dimension ;
|
||||||
|
`TerrainCensus.chunkSnapshot(level, pos)` fournit le détail d’un chunk déjà
|
||||||
|
chargé. Ces lectures se font sur le thread serveur et renvoient des valeurs
|
||||||
|
immuables. Elles parcourent les chunks `FULL` déjà accessibles, sans charger
|
||||||
|
de chunk absent ni attendre la fin d’une génération.
|
||||||
|
|
||||||
|
Le comptage utilise les palettes des sections de 16 × 16 × 16 blocs. Il regroupe
|
||||||
|
les orientations et autres propriétés sous l’identifiant du bloc. Les blocs
|
||||||
|
d’air sont exclus ; l’eau et la lave conservent leurs identifiants de blocs.
|
||||||
|
Le budget est de **16 sections non vides par tick serveur**, partagé entre les
|
||||||
|
dimensions et les demandes du même tick. Une section entièrement vide est
|
||||||
|
reconnue par ses métadonnées, sans parcourir sa palette.
|
||||||
|
|
||||||
|
Le relevé est progressif. Une section modifiée est retirée des totaux dès que
|
||||||
|
sa révision change, puis remise dans la file de comptage. Les sections et
|
||||||
|
chunks déchargés sortent du relevé. Un consommateur dispose, par dimension ou
|
||||||
|
chunk, du nombre de sections chargées et comptées, du tick de capture, du tick
|
||||||
|
du dernier comptage effectué et de l’âge de la plus ancienne attente.
|
||||||
|
`complete()` et `coverage()` décrivent la couverture **des zones chargées**.
|
||||||
|
Une couverture de 100 % ne signifie jamais que toute la sauvegarde a été lue.
|
||||||
|
|
||||||
|
Pendant un rattrapage, zéro bloc compté ne prouve donc pas l’absence de ce bloc.
|
||||||
|
Les futures règles d’expansion devront fixer leur couverture minimale et leur
|
||||||
|
zone d’étude avant de prendre une décision. Les résultats en mémoire sont
|
||||||
|
reconstruits après redémarrage ; ce service n’écrit pas dans les chunks et ne
|
||||||
|
modifie pas leur génération.
|
||||||
|
|
||||||
|
## Recensement des stocks présents
|
||||||
|
|
||||||
|
`StockCensus.snapshot(server)` photographie les objets accessibles au tick de
|
||||||
|
la demande, sur le thread serveur. Le parcours des slots et des entités chargées
|
||||||
|
est effectué à la demande ; aucun abonnement ne le relance à chaque tick.
|
||||||
|
Les quantités sont des unités d’items, tous
|
||||||
|
types confondus : un lingot, un outil ou une graine sont comptés même s’ils ne
|
||||||
|
correspondent pas à un bloc. Les composants, l’usure et les enchantements ne
|
||||||
|
créent pas de nouveaux identifiants de stock dans cette version.
|
||||||
|
|
||||||
|
| Catégorie | Couverture |
|
||||||
|
| --- | --- |
|
||||||
|
| `playerItems` | Joueurs connectés : inventaire personnel, équipement et curseur du menu. Le contenu du menu ouvert n’est pas additionné. |
|
||||||
|
| `blockContainerItems` | Entités de blocs déjà instanciées dans les chunks chargés, qui exposent l’interface `Container` : coffres, hoppers, fours, pots décorés, etc. Chaque moitié d’un double coffre est comptée une fois. |
|
||||||
|
| `entityContainerItems` | Entités chargées qui exposent `Container`, notamment les wagonnets et bateaux à coffre. |
|
||||||
|
| `groundItems` | Quantités des `ItemEntity` chargées et encore présentes au sol. |
|
||||||
|
|
||||||
|
Le relevé ne déballe **aucune table de butin**. Tout conteneur dont la table
|
||||||
|
reste à résoudre est ignoré avant la lecture de ses slots ; son existence
|
||||||
|
augmente `pendingLootContainers`. Les entités de blocs encore stockées sous
|
||||||
|
forme de NBT et non instanciées sont également laissées intactes, avec leur
|
||||||
|
nombre dans `pendingBlockEntities`. Leur contenu n’est pas remplacé par une
|
||||||
|
estimation.
|
||||||
|
|
||||||
|
La couverture exclut les joueurs hors ligne, les chunks et entités déchargés,
|
||||||
|
les ender chests, le contenu imbriqué des shulker boxes et bundles, les grilles
|
||||||
|
de fabrication en cours dans l’inventaire ou l’établi (`menuInputsIncluded()`
|
||||||
|
est faux), les inventaires et équipements des créatures. Le résultat de
|
||||||
|
fabrication affiché avant validation ne constitue pas un stock supplémentaire.
|
||||||
|
Elle exclut aussi les objets internes
|
||||||
|
des entités de blocs qui n’exposent pas `Container`, comme les aliments d’un feu
|
||||||
|
de camp ou le livre d’un pupitre. Une shulker box portée compte comme un item ;
|
||||||
|
une shulker box posée et chargée expose son conteneur. Les exclusions figurent
|
||||||
|
dans le contrat de couverture ; `partial()` reste vrai.
|
||||||
|
|
||||||
|
Ce relevé indique où se trouvent les unités observées. Il ne les réserve pas,
|
||||||
|
ne les transfère pas et n’évalue pas leur prix. Ajouter le terrain aux stocks
|
||||||
|
ne donne pas une quantité immédiatement vendable : une roche dans un chunk
|
||||||
|
reste différente d’un item dans un coffre.
|
||||||
|
|
||||||
|
## Consultation et futurs consommateurs communs
|
||||||
|
|
||||||
|
`WorldCensusService.capture(server)` rassemble les deux relevés pour une même
|
||||||
|
lecture côté serveur. Le consommateur réutilise cet instantané pendant son
|
||||||
|
calcul et conserve ses informations de couverture. Le shop et les expansions
|
||||||
|
pourront ainsi consulter la même base, avec leurs propres règles de fraîcheur,
|
||||||
|
de portée géographique, de prix, de consommation et de réservation.
|
||||||
|
|
||||||
|
Les diagnostics suivants sont réservés aux opérateurs :
|
||||||
|
|
||||||
|
```text
|
||||||
|
/sanctuary census
|
||||||
|
/sanctuary census block minecraft:stone
|
||||||
|
/sanctuary census item minecraft:gold_ingot
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résumé présente la couverture du terrain par dimension, les quatre catégories
|
||||||
|
de stocks et les conteneurs au butin encore indéterminé. Le Blocodex personnel
|
||||||
|
accessible par **B** conserve les données du joueur ; ces diagnostics ne diffusent
|
||||||
|
pas les inventaires agrégés aux autres joueurs.
|
||||||
|
|
||||||
|
## Relevés datés — portée de l’alpha.23
|
||||||
|
|
||||||
|
La collecte regroupe les activités observées par **jour civil UTC**, pour
|
||||||
|
l’ensemble du serveur, sans attribution à un UUID ou à une dimension :
|
||||||
|
|
||||||
|
| Action | Registre et unité |
|
||||||
|
| --- | --- |
|
||||||
|
| `mined` | `block` : nombre de blocs minés, selon les règles des statistiques vanilla. |
|
||||||
|
| `placed` | `block` : placements réussis par le joueur, créatif inclus et spectateurs exclus. |
|
||||||
|
| `picked_up` | `item` : unités ramassées observées par les statistiques vanilla. |
|
||||||
|
| `dropped` | `item` : unités volontairement jetées observées par les statistiques vanilla. |
|
||||||
|
| `crafted` | `item` : unités fabriquées créditées au joueur par les statistiques vanilla. |
|
||||||
|
|
||||||
|
Chaque combinaison conserve son action, son registre, son identifiant et une
|
||||||
|
quantité entière sur 64 bits, saturée à la valeur maximale en cas de dépassement.
|
||||||
|
Un compteur de minage n’est pas transformé en nombre d’items produits. Les actions
|
||||||
|
antérieures à l’installation ne sont pas réparties artificiellement entre des
|
||||||
|
dates à partir des statistiques cumulées de Minecraft.
|
||||||
|
|
||||||
|
`MaterialActivityHistory.snapshot(server, day)` expose un instantané immuable
|
||||||
|
avec le jour, les compteurs, `recorded` et `unsaved`. Un jour sans données
|
||||||
|
enregistrées demeure inconnu ; il ne constitue pas un historique d’activité nulle.
|
||||||
|
Les opérateurs peuvent consulter `/sanctuary history` ou préciser une date avec
|
||||||
|
`/sanctuary history 2026-09-10`. La commande affiche au plus les 16 lignes aux
|
||||||
|
quantités les plus élevées et indique combien ont été omises ; l’API conserve
|
||||||
|
l’ensemble des compteurs du jour.
|
||||||
|
|
||||||
|
La persistance additionnelle utilise `data/sanctuary-activity/YYYY-MM-DD.json`,
|
||||||
|
avec un schéma 1 et des dates UTC. Elle est séparée de la mémoire personnelle
|
||||||
|
et des chunks. Les écritures ont lieu tous les **600 ticks**, à la sauvegarde
|
||||||
|
et à l’arrêt du serveur, ainsi qu’au changement de jour observé par le service.
|
||||||
|
Un crash peut perdre les derniers relevés non sauvegardés, soit normalement
|
||||||
|
jusqu’à 30 secondes de jeu à 20 ticks par seconde. Le fichier conserve les
|
||||||
|
identifiants de mods absents ; il refuse les schémas inconnus, doublons,
|
||||||
|
compteurs invalides et changements externes. Une erreur arrête la collecte et
|
||||||
|
rend le service indisponible jusqu’à réparation puis redémarrage, en conservant
|
||||||
|
le fichier en cause. Les limites sont de 100 000 tuples et 16 Mio par jour.
|
||||||
|
|
||||||
|
Ces relevés permettent de distinguer des périodes d’activité. Ils ne conservent
|
||||||
|
pas d’historique des instantanés de stocks, ne couvrent pas la fabrication automatique des
|
||||||
|
machines et n’établissent ni consommation, ni perte, ni destination des objets.
|
||||||
|
Ils ne retracent pas chaque transfert entre coffres ou chaque changement de propriétaire, et
|
||||||
|
ne constituent pas un journal transactionnel d’achats, de récompenses ou de
|
||||||
|
réservations. Le **ballast**, ses unités, ses conversions et sa traduction en
|
||||||
|
géologie restent à concevoir dans le [cahier de cosmologie](cosmologie.md).
|
||||||
|
Aucun objet n’est consommé et aucune Backroom n’est générée par ces mesures.
|
||||||
|
|
||||||
|
## Référence WSMC retrouvée
|
||||||
|
|
||||||
|
La recherche locale a retrouvé une partie du travail précédent dans
|
||||||
|
`/Users/koka/Documents/sanctuary/Gameplay/blocodex` :
|
||||||
|
`ClientVoxelProjectBuilder` convertit des apparences de blocs et d’items en
|
||||||
|
volumes/couches de blocs ; `BlocodexStudioPalettes` sélectionne des matériaux
|
||||||
|
opaques appris. Le projet conserve aussi un instantané WSMC de 1 881 entrées
|
||||||
|
(1 168 blocs, 490 items, 223 mobs), issu de Minecraft 1.21.11.
|
||||||
|
|
||||||
|
Cette référence reste historique. La nouvelle API fournit la connaissance et
|
||||||
|
les candidats de palette ; la traduction de modèles d’entités en structures
|
||||||
|
(visage de creeper, squelette géant, etc.) reste un chantier distinct. Aucun
|
||||||
|
générateur d’entités ni import de ce snapshot ancien n’est ajouté au registre
|
||||||
|
de Minecraft 26.3 par le socle méta.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Validation locale du 10 septembre 2026 : `./gradlew check build assemblePack`
|
||||||
|
passe, avec **24 GameTests natifs réussis**. Le client graphique vérifie les
|
||||||
|
widgets et les messages réels dans un nouveau monde intégré, avec B sans droit
|
||||||
|
opérateur, observation serveur et possession conservée après retrait du stock.
|
||||||
|
Les tests de stockage passent. Les [preuves et limites](testing-alpha23.md)
|
||||||
|
détaillent le protocole, l’artefact et son empreinte. Alpha.23 assemblée localement ;
|
||||||
|
publication du MRpack et mise à jour du canal demandées, en préparation.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Sanctuary — matière, espaces et mémoire
|
||||||
|
|
||||||
|
**Cahier de conception issu du texte de l’auteur.** Il prolonge
|
||||||
|
[la vision](vision.md) et [la conception des lieux](structures-conception.md).
|
||||||
|
Les citations conservent les formulations fournies ; les points à préciser
|
||||||
|
sont identifiés comme tels. Ce document ne livre aucun générateur ni système
|
||||||
|
économique. **Backrooms, Indoors, shop et ballast ne sont pas implémentés.**
|
||||||
|
Le Blocodex natif de l’alpha.23 locale fournit un premier socle de données
|
||||||
|
pour des mécaniques futures, avec les limites décrites dans [son contrat](blocodex.md).
|
||||||
|
|
||||||
|
## Minecraft comme archéologie d’un autre Minecraft
|
||||||
|
|
||||||
|
Steve, Ari, Sunny, Kai, Zuri, Alex, Mefe, Makena et Noor sont les neuf personnages
|
||||||
|
disparus de la partie. Ils ont découvert quelque chose de révolutionnaire et ont
|
||||||
|
continué parce que ça marchait. Jusqu’au moment où ça n’a plus marché.
|
||||||
|
|
||||||
|
> Ils n’ont pas ouvert quelque chose qu’ils n’auraient pas dû ouvrir. Ils ont
|
||||||
|
> construit quelque chose qu’ils ne savaient plus refermer.
|
||||||
|
|
||||||
|
L’histoire part de quelque chose qui a fonctionné : des besoins satisfaits, des
|
||||||
|
lieux devenus utilisables, des opérations répétées et des installations agrandies.
|
||||||
|
Une ruine doit permettre de retrouver cette ancienne solution. Ses circulations,
|
||||||
|
ses raccords, ses réparations et ses extensions racontent un fonctionnement avant
|
||||||
|
de raconter son interruption.
|
||||||
|
|
||||||
|
> Chaque ruine de sanctuary doit être une trace d’une ancienne solution pas
|
||||||
|
> seulement la trace d’un problème.
|
||||||
|
|
||||||
|
La découverte précise, la succession des expériences et les rôles des neuf
|
||||||
|
personnages restent à écrire. Aucun métier, aucune responsabilité individuelle
|
||||||
|
ni cause définitive de leur disparition ne leur sont attribués ici. On conserve
|
||||||
|
en particulier **Mefe**, tel que nommé par l’auteur. Il n’est pas décidé que
|
||||||
|
chacun possède un bâtiment, une machine, une gemme ou une quête correspondante.
|
||||||
|
|
||||||
|
## Deux devenirs de la même technologie
|
||||||
|
|
||||||
|
Les Indoors et les Backrooms appartiennent à la même logique de fabrication des
|
||||||
|
espaces. Ce qui les distingue est leur relation à une intention et à une
|
||||||
|
référence : sait-on encore pourquoi cet espace existe et comment le retrouver ?
|
||||||
|
|
||||||
|
| Espace | Direction donnée par l’auteur | Conséquence pour la conception |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Indoor** | « espace artificiel référencé et contrôlé » | Un intérieur est produit intentionnellement et reste associé à son accès et à son usage. Une porte peut mener à un espace dix fois plus grand que le bâtiment extérieur. |
|
||||||
|
| **Backroom** | « espace artificiel orphelin » | Un espace demeure alors que sa destination ou sa raison d’être a disparu. Sa forme peut conserver les restes d’une fonction sans retrouver l’ensemble auquel elle appartenait. |
|
||||||
|
|
||||||
|
L’auteur décrit chaque Indoor comme une petite dimension instanciée. Les
|
||||||
|
computers utilisent **probablement** cette technologie pour leurs expériences :
|
||||||
|
cette relation est une piste forte, sans avoir encore de protocole ni de machine
|
||||||
|
définitivement assignée.
|
||||||
|
|
||||||
|
Les Backrooms, dans cette cosmologie, ne sont pas une destination demandée au
|
||||||
|
même titre qu’un monde. Elles sont ce que la machine produit lorsqu’elle ne sait
|
||||||
|
pas où mettre quelque chose :
|
||||||
|
|
||||||
|
- un espace généré puis annulé ;
|
||||||
|
- une expérience interrompue ;
|
||||||
|
- deux règles demandant deux états incompatibles ;
|
||||||
|
- une room qui n’est plus référencée ;
|
||||||
|
- une coordonnée qu’aucun monde ne revendique.
|
||||||
|
|
||||||
|
> La machine ne détruit pas parfaitement les espaces. Elle les laisse derrière.
|
||||||
|
> Backrooms.
|
||||||
|
|
||||||
|
L’image du « garbage collector raté de Minecraft » exprime cette accumulation.
|
||||||
|
L’arborescence proposée par l’auteur la représente ainsi :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WORLD
|
||||||
|
├── overworld
|
||||||
|
├── caverns
|
||||||
|
├── nether
|
||||||
|
├── end
|
||||||
|
├── sanctuary
|
||||||
|
└── /unreferenced/
|
||||||
|
└── backrooms
|
||||||
|
```
|
||||||
|
|
||||||
|
C’est une représentation narrative, pas une arborescence de fichiers ni la
|
||||||
|
liste des dimensions actuellement enregistrées par le mod. **L’espace orphelin
|
||||||
|
est une fiction à mettre en scène.** Une réalisation Minecraft devra conserver
|
||||||
|
des espaces correctement sauvegardés, identifiés et référencés côté serveur.
|
||||||
|
Le choix entre dimensions techniques et régions d’un espace partagé reste à
|
||||||
|
concevoir. Rien ici ne prévoit de perdre de vraies références de sauvegarde,
|
||||||
|
de supprimer des chunks ou de provoquer leur corruption pour obtenir cet effet.
|
||||||
|
|
||||||
|
## De la matière sans contexte
|
||||||
|
|
||||||
|
Le système reçoit des matériaux et tente de les réorganiser en espace valide :
|
||||||
|
|
||||||
|
```text
|
||||||
|
stone stone dirt copper oak_planks iron stone glass ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Il possède de la matière, mais aucune destination n’a été demandée. Il tente
|
||||||
|
alors de l’encoder sous forme de chunks : un couloir, une salle, encore une
|
||||||
|
salle, un plafond, une porte donnant sur rien, une plomberie sans bâtiment,
|
||||||
|
un entrepôt infini.
|
||||||
|
|
||||||
|
> Les Backrooms sont la tentative du générateur de Minecraft de faire quelque
|
||||||
|
> chose avec de la matière sans contexte.
|
||||||
|
|
||||||
|
La **matière** donne une palette et une présence physique. Le **contexte** lui
|
||||||
|
donnait un usage, des dimensions, des voisins, un accès ou une destination.
|
||||||
|
Cette distinction permet de dessiner un lieu dont les parties sont intelligibles
|
||||||
|
alors que leur assemblage ne retrouve plus son intention : une plomberie garde
|
||||||
|
sa logique de raccord, mais le bâtiment qu’elle devait desservir n’est plus là.
|
||||||
|
Elle ne justifie pas de remplacer la conception par des blocs placés au hasard.
|
||||||
|
|
||||||
|
Il reste à définir ce que la machine conserve de ce contexte : seulement les
|
||||||
|
matériaux, des fragments de plans, des types de pièces, des relations entre
|
||||||
|
espaces ou des traces d’opérations. Aucun catalogue de rooms ni algorithme de
|
||||||
|
recomposition n’est arrêté. Les palettes limitées et le futur moteur de
|
||||||
|
traduction en structures peuvent servir cette direction ; le moteur n’est pas
|
||||||
|
créé par ce document.
|
||||||
|
|
||||||
|
## Le ballast de l’économie
|
||||||
|
|
||||||
|
La direction fournie relie directement cette cosmologie aux actions des joueurs.
|
||||||
|
Utiliser le shop, téléporter, ouvrir des Indoors, déplacer de la matière ou
|
||||||
|
expandre le monde produit du **ballast**. Plus ces opérations se multiplient,
|
||||||
|
plus les Backrooms se remplissent. Elles deviennent l’externalité du système
|
||||||
|
économique de Sanctuary : une activité visible laisse ailleurs des conséquences
|
||||||
|
que ses utilisateurs pourront découvrir beaucoup plus tard.
|
||||||
|
|
||||||
|
Le ballast relie donc production, échanges et géographie. Un objet déplacé ne
|
||||||
|
change pas seulement de propriétaire ou de position dans cette vision ; son
|
||||||
|
passage par ces technologies peut laisser une empreinte dans le monde.
|
||||||
|
La forme de cette empreinte reste à décider pour chaque opération.
|
||||||
|
|
||||||
|
| Opération envisagée | Articulation à définir avant de l’implémenter |
|
||||||
|
| --- | --- |
|
||||||
|
| Shop et échanges | Quel événement produit du ballast : vente, achat, consommation par un service, autre opération ? Quelle matière lui correspond et à quel moment l’échange est-il terminé ? |
|
||||||
|
| Téléportation | Que laisse le déplacement : trace de matière transportée, coût matériel ou autre résidu à choisir ? Les types de téléportation partagent-ils cette règle ? |
|
||||||
|
| Ouverture d’un Indoor | Que produit la création d’un espace contrôlé ? Que deviennent ensuite une fermeture, un transfert d’accès ou une expérience interrompue ? |
|
||||||
|
| Déplacement de matière | Quelles opérations participent à cette économie ? Un transport manuel, un convoyeur, un stockage ou une transformation ne sont pas encore déclarés équivalents. |
|
||||||
|
| Expansion | Comment la préparation et la création d’un continent se rattachent-elles aux matériaux engagés et aux espaces laissés derrière ? |
|
||||||
|
|
||||||
|
« Ballast » ne fixe pas encore une quantité, une unité, un coût ou une monnaie.
|
||||||
|
Il faut notamment décider s’il représente une matière effectivement retirée,
|
||||||
|
une trace de son traitement ou les deux selon les opérations. Aucun taux de
|
||||||
|
conversion ni conservation quantitative stricte n’est établi. Le document
|
||||||
|
n’autorise donc pas à prélever des objets dans les inventaires ou à les dupliquer
|
||||||
|
dans des coffres au nom de cette cosmologie.
|
||||||
|
|
||||||
|
## Lire l’histoire économique dans la géologie
|
||||||
|
|
||||||
|
La génération doit dépendre du comportement réel des joueurs. L’auteur donne
|
||||||
|
trois exemples qui fixent la direction sans constituer des recettes de génération :
|
||||||
|
|
||||||
|
| Histoire du serveur | Trace possible dans les Backrooms |
|
||||||
|
| --- | --- |
|
||||||
|
| Le serveur consomme énormément de pierre. | Les premières couches deviennent minérales. |
|
||||||
|
| Une phase d’industrialisation utilise beaucoup de cuivre. | Des zones techniques apparaissent. |
|
||||||
|
| Des tonnes de bois sont sacrifiées. | Un gigantesque Indoor abandonné peut émerger. |
|
||||||
|
|
||||||
|
Des mois après, les joueurs peuvent descendre et reconnaître dans les lieux
|
||||||
|
traversés une période de l’histoire du serveur. Le choix des matériaux doit
|
||||||
|
pouvoir conserver une différence entre une phase minérale, une phase industrielle
|
||||||
|
et d’autres formes de production, au lieu de tout réduire à la dernière palette
|
||||||
|
dominante.
|
||||||
|
|
||||||
|
Cette intention demande une temporalité explicite. Deux histoires se rencontrent :
|
||||||
|
celle des neuf personnages, antérieure à l’arrivée des joueurs, et celle que le
|
||||||
|
serveur écrit pendant la partie. La première fournit les vestiges d’un autre
|
||||||
|
Minecraft ; la seconde peut produire de nouveaux dépôts et de nouvelles traces.
|
||||||
|
La présence de strates initiales issues de l’ancienne partie, leur contenu et
|
||||||
|
leur articulation avec les apports actuels restent à décider.
|
||||||
|
|
||||||
|
Il faut encore choisir la durée des périodes observées, ce qui forme un dépôt,
|
||||||
|
le moment où un dépôt devient un lieu et la manière dont les périodes se
|
||||||
|
succèdent dans l’espace. Une couche n’est pas nécessairement un niveau vertical :
|
||||||
|
la géographie de cette chronologie reste à dessiner. La règle devra préserver
|
||||||
|
les espaces déjà visités et construits ; changer les statistiques actuelles ne
|
||||||
|
doit pas devenir une régénération implicite d’anciens chunks.
|
||||||
|
|
||||||
|
## Ce que le socle méta peut fournir
|
||||||
|
|
||||||
|
Le Blocodex appartient à Sanctuary et peut être consulté par plusieurs mécaniques.
|
||||||
|
Il distingue actuellement connaissance, observation, minage, possession passée,
|
||||||
|
jet, pose et stock porté. Les critères de connaissance et les familles de
|
||||||
|
matériaux peuvent aider un futur moteur à constituer une palette utilisable.
|
||||||
|
|
||||||
|
Ces informations personnelles ne suffisent cependant pas à décrire l’histoire économique :
|
||||||
|
|
||||||
|
- **miné** ne veut pas dire consommé par le shop ou engagé dans une machine ;
|
||||||
|
- **jeté** compte les jets volontaires vanilla, pas les objets brûlés ou perdus
|
||||||
|
dans le vide ;
|
||||||
|
- **déjà possédé** est une preuve persistante, pas un stock encore disponible ;
|
||||||
|
- **posé** ne fournit pas à lui seul le plan d’une construction ni sa date ;
|
||||||
|
- le stock personnel ne recense pas tous les coffres et réseaux du serveur.
|
||||||
|
|
||||||
|
Le socle méta distingue maintenant trois relevés :
|
||||||
|
|
||||||
|
| Relevé | Ce qu’il permet de connaître |
|
||||||
|
| --- | --- |
|
||||||
|
| Mémoire personnelle | Ce qu’un joueur a vu, miné, possédé, jeté ou posé ; des critères pour ses palettes. |
|
||||||
|
| Distribution actuelle | Les blocs du terrain chargé et les stocks accessibles, comptés séparément avec leur couverture. |
|
||||||
|
| Activité datée du serveur | Les quantités minées, posées, ramassées, jetées et fabriquées, agrégées par jour UTC depuis l’installation. |
|
||||||
|
|
||||||
|
Le troisième relevé conserve une trace des périodes d’activité après le
|
||||||
|
déplacement des stocks. Il peut montrer une période de minage intense de cuivre,
|
||||||
|
mais ne prouve pas que ce cuivre a été sacrifié à une machine. Il ne reconstruit
|
||||||
|
pas les mois précédant son installation et ne calcule aucun ballast. Les limites
|
||||||
|
de persistance et de couverture figurent dans le [contrat méta](blocodex.md).
|
||||||
|
|
||||||
|
Avant la génération historique, il faudra donc définir quels faits les systèmes
|
||||||
|
économiques enregistrent : action réalisée, matériau, quantité, moment ou période,
|
||||||
|
origine et destination utiles à cette action. Leur traitement devra distinguer
|
||||||
|
une opération terminée d’une tentative refusée et éviter de compter plusieurs
|
||||||
|
fois la même opération après reprise. La granularité, la conservation et le
|
||||||
|
format de ces transactions restent à concevoir dans un contrat propre. Les
|
||||||
|
agrégats journaliers déjà disponibles ne sont pas un journal transactionnel :
|
||||||
|
ils ne doivent autoriser ni prélèvement, ni récompense, ni restitution d’objet.
|
||||||
|
|
||||||
|
## Machines, références et anciennes solutions
|
||||||
|
|
||||||
|
Les computers pourraient expérimenter dans les Indoors parce qu’ils offrent
|
||||||
|
des espaces contrôlés. Il reste à préciser ce qu’ils demandent à un espace et
|
||||||
|
comment cette demande le rattache à une installation, une porte ou un usage.
|
||||||
|
La cause d’un orphelin dans la fiction devra être compréhensible sans demander
|
||||||
|
au joueur de connaître les fichiers de sauvegarde.
|
||||||
|
|
||||||
|
L’assembleur, le contrôleur, le terminal, le dépôt et l’ancre spatiale restent
|
||||||
|
des concepts en discussion dans [le cahier des machines](structures-conception.md#ordinateurs-et-installation-dexpansion).
|
||||||
|
Les questions communes sont maintenant plus précises : qui formule la demande
|
||||||
|
d’espace, qui fournit sa matière, qui conserve sa référence, qui présente son
|
||||||
|
état, et comment une fermeture ou une interruption est représentée ? Cette liste
|
||||||
|
ne répartit pas arbitrairement cinq fonctions entre cinq blocs.
|
||||||
|
|
||||||
|
Les ruines peuvent rendre ces questions visibles par leurs usages passés.
|
||||||
|
L’observatoire, la salle d’expansion rééquipable, l’atelier caché et la grande
|
||||||
|
traversée restent la sélection de conception actuelle. Pour chacun, il faut
|
||||||
|
retrouver ce qui y était observé, préparé, fabriqué ou relié, puis montrer les
|
||||||
|
extensions et le travail interrompu. Ce sont des questions de fiche de lieu,
|
||||||
|
pas la confirmation de machines présentes ni d’un accès aux Backrooms dans
|
||||||
|
chaque ruine. Le donjon majeur conserve son boss et son objet de quête à définir ;
|
||||||
|
il n’est pas automatiquement identifié au réacteur ou à l’origine de la panne.
|
||||||
|
|
||||||
|
La même ligne relie le passé et la partie actuelle :
|
||||||
|
|
||||||
|
> une infrastructure dont l’auteur a progressivement disparu derrière son
|
||||||
|
> fonctionnement.
|
||||||
|
|
||||||
|
## Accès, retour et récupération : règles encore ouvertes
|
||||||
|
|
||||||
|
La vision antérieure envisage le lit pour entrer dans les Backrooms, des sources
|
||||||
|
de lumière pour les explorer et des coffres recueillant des objets perdus dans
|
||||||
|
le vide ou brûlés. Elle prévoit aussi des accès d’Indoors liés à la mailbox de
|
||||||
|
leur propriétaire. Ces intentions doivent être raccordées à la cosmologie du
|
||||||
|
ballast ; elles ne définissent pas encore un parcours complet.
|
||||||
|
|
||||||
|
Avant un ticket jouable, il faut notamment arrêter :
|
||||||
|
|
||||||
|
1. Les moyens d’entrer, les destinations d’arrivée, le retour et le comportement
|
||||||
|
lors d’une mort ou d’une déconnexion.
|
||||||
|
2. La propriété et le partage des Indoors, la perte de leur objet d’accès, leur
|
||||||
|
fermeture et la distinction entre un accès absent et un espace fictionnellement
|
||||||
|
devenu orphelin.
|
||||||
|
3. Le rapport entre ballast, géologie et objets récupérables : quelle part fait
|
||||||
|
décor ou ressource, quelle part demeure un objet identifiable, et comment sa
|
||||||
|
récupération évite une restitution multiple.
|
||||||
|
4. Les opérations qui alimentent réellement les dépôts, leurs unités, leur
|
||||||
|
cadence et les limites de stockage et de génération.
|
||||||
|
5. Les indices permettant de lire une période économique ou une ancienne solution,
|
||||||
|
sans attribuer d’avance une cause à tous les lieux et aux neuf disparus.
|
||||||
|
|
||||||
|
Les pool rooms et salles étranges physiques sous l’île restent des structures
|
||||||
|
de l’Overworld. Leur ressemblance éventuelle avec les Backrooms ne les transforme
|
||||||
|
pas automatiquement en espaces orphelins. Les passages entre ces lieux, les
|
||||||
|
Indoors et les Backrooms devront être décidés explicitement.
|
||||||
|
|
||||||
|
La prochaine étape de conception peut prendre un seul cas complet : une
|
||||||
|
opération économique réelle, la trace qu’elle conserve, son dépôt, puis le lieu
|
||||||
|
que ce dépôt permettrait de générer et d’explorer. Ce cas permettrait de fixer un
|
||||||
|
premier contrat vérifiable sans prétendre que toute la cosmologie est déjà jouable.
|
||||||
@@ -60,7 +60,7 @@ automatique à la sélection.
|
|||||||
| Atelier caché | Petit espace personnel aménagé derrière une entrée secondaire ou dans un ouvrage. | Abri, outillage ou équipement utile ; indices d’un travail interrompu. Contenu exact à choisir. | Modèle d’une installation reproductible ; aucune caméra ou machine encore absente promise dans les coffres. |
|
| Atelier caché | Petit espace personnel aménagé derrière une entrée secondaire ou dans un ouvrage. | Abri, outillage ou équipement utile ; indices d’un travail interrompu. Contenu exact à choisir. | Modèle d’une installation reproductible ; aucune caméra ou machine encore absente promise dans les coffres. |
|
||||||
| Donjon majeur | Ancienne fonction, implantation et cause de danger à concevoir ; le lieu doit conserver la trace d’une ancienne solution. | Objectif de conception : une exploration difficile, une rencontre avec un boss et un objet de quête nécessaire à la progression. Aucune rencontre n’est implémentée par ce document. | Étape de progression à choisir ; règles collectives, répétition du combat et attribution de l’objet encore ouvertes. |
|
| Donjon majeur | Ancienne fonction, implantation et cause de danger à concevoir ; le lieu doit conserver la trace d’une ancienne solution. | Objectif de conception : une exploration difficile, une rencontre avec un boss et un objet de quête nécessaire à la progression. Aucune rencontre n’est implémentée par ce document. | Étape de progression à choisir ; règles collectives, répétition du combat et attribution de l’objet encore ouvertes. |
|
||||||
| Excavation rare — alternative | Ancien front d’extraction, lié à une masse rocheuse et à un gisement réel. | Descendre dans des grottes contenant des ressources encore exploitables. Richesse et rareté à équilibrer. | Extraction, transport et production construits par les joueurs. |
|
| Excavation rare — alternative | Ancien front d’extraction, lié à une masse rocheuse et à un gisement réel. | Descendre dans des grottes contenant des ressources encore exploitables. Richesse et rareté à équilibrer. | Extraction, transport et production construits par les joueurs. |
|
||||||
| Piscine ou pool rooms — alternative | Installation hydraulique avec un premier usage identifiable, puis des espaces plus mystérieux. | Exploration, circulations humides et sèches, passages vers les profondeurs. | Éventuel donjon physique ; distinct de la dimension Backrooms des objets perdus. |
|
| Piscine ou pool rooms — alternative | Installation hydraulique avec un premier usage identifiable, puis des espaces plus mystérieux. | Exploration, circulations humides et sèches, passages vers les profondeurs. | Éventuel donjon physique ; distinct des Backrooms, espaces artificiels orphelins dont l’histoire et l’hébergement restent à concevoir selon la [cosmologie](cosmologie.md). |
|
||||||
|
|
||||||
La traversée est une infrastructure de circulation, pas un quota de bâtiments
|
La traversée est une infrastructure de circulation, pas un quota de bâtiments
|
||||||
annexes. Ses sorties peuvent être isolées. Les égouts peuvent exister ailleurs
|
annexes. Ses sorties peuvent être isolées. Les égouts peuvent exister ailleurs
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Validation alpha.23 — Blocodex et socle méta
|
||||||
|
|
||||||
|
Relevé du **10 septembre 2026**, branche `codex/blocodex-natif`.
|
||||||
|
Cible : Minecraft **26.3-pre-2**, Fabric Loader **0.19.5**, Fabric API
|
||||||
|
**0.160.0+26.3**, Java **25**, Sanctuary/pack **0.1.0-alpha.23**.
|
||||||
|
Le [contrat Blocodex](blocodex.md) définit les informations et leur couverture.
|
||||||
|
|
||||||
|
## Résultats disponibles
|
||||||
|
|
||||||
|
| Vérification | Résultat observé | Preuve locale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Client natif : widgets, interface et connexion au serveur intégré | **Réussie**, `BUILD SUCCESSFUL in 34s` | `build/blocodex-client-network-tests.log` |
|
||||||
|
| Compilation et persistance quotidienne des activités | **Réussies**, `BUILD SUCCESSFUL in 9s` | `build/blocodex-meta-compile.log` |
|
||||||
|
| Persistance de la mémoire personnelle | Smoke réussi dans la passe complète | `build/blocodex-meta-check-build.log`, ligne `Block knowledge smoke passed` |
|
||||||
|
| GameTests natifs | **24/24 réussis**, dont 16 pour le socle méta | `build/blocodex-meta-check-build.log`, `All 24 required tests passed` |
|
||||||
|
| Passe complète et assemblage du pack | **Réussis**, `BUILD SUCCESSFUL in 4m 18s` | `build/blocodex-meta-check-build.log` |
|
||||||
|
|
||||||
|
Les tests de stockage couvrent les instantanés immuables, les compteurs exacts,
|
||||||
|
l’isolement des UUID ou des journées, les identifiants de mods absents, le JSON
|
||||||
|
strict et le refus d’écraser silencieusement un fichier invalide. Leur succès
|
||||||
|
est complété par les tests natifs ci-dessous.
|
||||||
|
|
||||||
|
Les neuf tests de connaissance vérifient poses réussies et refusées, variantes
|
||||||
|
murales, statistiques de minage et jets volontaires, distinction des morts,
|
||||||
|
regard occulté, inventaire/curseur, états et notifications indépendants,
|
||||||
|
sauvegarde après déconnexion depuis un thread réseau, et palettes combinables.
|
||||||
|
Deux tests terrain vérifient pose/remplacement/retrait, exclusion immédiate des
|
||||||
|
comptages obsolètes, couverture et budget partagé entre lectures, sans charger
|
||||||
|
de chunk absent. Deux tests stocks vérifient les quatre catégories, un double
|
||||||
|
coffre ouvert sans double comptage et la conservation du butin non généré.
|
||||||
|
Trois tests d’activité vérifient les actions natives, les quantités datées,
|
||||||
|
la séparation des observations et des actions, la relecture après sauvegarde,
|
||||||
|
les journées absentes et les commandes avec identifiants complets. Le test de
|
||||||
|
fabrication appelle la complétion vanilla d’un item ; il ne joue pas une recette
|
||||||
|
entière dans l’établi. Les huit tests généraux de génération passent aussi.
|
||||||
|
|
||||||
|
## Artefact local vérifié
|
||||||
|
|
||||||
|
Le JAR `mods/sanctuary/build/libs/sanctuary-0.1.0-alpha.23.jar` mesure
|
||||||
|
**1 914 798 octets**. Sa copie dans `build/packwiz/mods/` est identique.
|
||||||
|
SHA-256 :
|
||||||
|
|
||||||
|
```text
|
||||||
|
740107901c06d1c54838a944d01431e4f134f6989e1900b294cc0c0d210b1d4f
|
||||||
|
```
|
||||||
|
|
||||||
|
Le manifeste embarqué, l’entrée client, les classes de recensement et
|
||||||
|
d’historique, le tag de palette naturelle et les paramètres des traductions
|
||||||
|
FR/EN sont vérifiés. Les manifestes du dépôt et du pack assemblé indiquent
|
||||||
|
`0.1.0-alpha.23`. Le contrôle pack vérifie les versions et empreintes de l’index.
|
||||||
|
|
||||||
|
## Protocole client exécuté
|
||||||
|
|
||||||
|
Le client de développement commence par une fixture explicite de **260 blocs
|
||||||
|
en trois fragments**. Les widgets réels vérifient recherche, filtres et
|
||||||
|
pagination, assemblage atomique, rejet des réponses périmées ou invalides,
|
||||||
|
et conservation des grands compteurs dans la narration. Deux dimensions de
|
||||||
|
GUI sont exercées : **854 × 480** et **427 × 240**, avec une aide défilante.
|
||||||
|
Cette fixture ne prétend pas représenter un inventaire de joueur.
|
||||||
|
|
||||||
|
La seconde passe crée un **nouveau monde plat de développement**, graine 1,
|
||||||
|
en survie, avec les commandes désactivées. Elle utilise la véritable touche
|
||||||
|
**B**, la connexion Minecraft et les paquets du mod :
|
||||||
|
|
||||||
|
1. Préparer côté serveur un bloc d’or devant le joueur et trois blocs de diamant
|
||||||
|
dans son inventaire ; laisser les observations ordinaires du serveur agir.
|
||||||
|
2. Regarder l’or puis ouvrir B : l’or est vu sans être possédé ; le diamant est
|
||||||
|
possédé avec un stock de 3 sans être vu ; l’émeraude reste inconnue.
|
||||||
|
3. Fermer l’écran, retirer les diamants, puis rouvrir avec B : la possession
|
||||||
|
passée reste vraie et le stock actuel vaut 0.
|
||||||
|
4. Fermer le monde et vérifier le fichier de mémoire personnelle sauvegardé.
|
||||||
|
|
||||||
|
Les deux diagnostics signalent `passed: true` dans
|
||||||
|
`mods/sanctuary/build/run/clientGameTest/diagnostics/` :
|
||||||
|
|
||||||
|
- `sanctuary-blocodex-client.json` : fixture, widgets et fragments ;
|
||||||
|
- `sanctuary-blocodex-network-client.json` : états avant/après, touche et paquets
|
||||||
|
réels, observation du regard et sauvegarde à la fermeture.
|
||||||
|
|
||||||
|
Les captures `0005_sanctuary-blocodex-network-carried-three.png` et
|
||||||
|
`0006_sanctuary-blocodex-network-sticky-possession.png` se trouvent dans le dossier
|
||||||
|
`screenshots/` du même client. La seconde a été inspectée : le diamant reste
|
||||||
|
connu et possédé, avec **Inventory: 0**, dans l’interface anglaise du test.
|
||||||
|
|
||||||
|
## Reproduction et limites
|
||||||
|
|
||||||
|
Depuis la racine du dépôt, avec Java 25 et un environnement graphique :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew -PsanctuaryClientTests=true :sanctuary:runClientGameTest
|
||||||
|
./gradlew :sanctuary:materialActivitySmoke
|
||||||
|
./gradlew check build assemblePack
|
||||||
|
```
|
||||||
|
|
||||||
|
La dernière commande a terminé avec succès. Le scénario client prouve une
|
||||||
|
connexion intégrée réelle ; il ne constitue pas une validation
|
||||||
|
réseau à plusieurs clients sur un serveur distant.
|
||||||
|
Les essais ne constituent pas un profil de performance d’un grand serveur.
|
||||||
|
|
||||||
|
Les mondes, captures, diagnostics et journaux de test restent dans les dossiers
|
||||||
|
de développement ignorés. Le scénario réseau vérifie que sa nouvelle sauvegarde
|
||||||
|
est créée sous `mods/sanctuary/build/run/clientGameTest/saves/`. Aucune instance
|
||||||
|
Prism ni sauvegarde personnelle n’a été mise à jour. Aucune publication ni
|
||||||
|
modification du canal packwiz n’a été effectuée pour ces essais.
|
||||||
+35
-5
@@ -43,6 +43,16 @@ catastrophe précise n’est confirmée par ce principe. **La mise en scène de
|
|||||||
histoire et ses liens avec la progression restent à implémenter** ; les versions
|
histoire et ses liens avec la progression restent à implémenter** ; les versions
|
||||||
livrées et leurs tests établissent ce que chaque structure contient effectivement.
|
livrées et leurs tests établissent ce que chaque structure contient effectivement.
|
||||||
|
|
||||||
|
Les **Indoors** prolongent cette histoire comme espaces artificiels intentionnels,
|
||||||
|
référencés et contrôlés. Les **Backrooms** en sont les espaces orphelins : la
|
||||||
|
machine tente d’organiser de la matière dont elle a perdu le contexte. Le ballast
|
||||||
|
produit par les échanges, déplacements et créations d’espaces doit pouvoir y
|
||||||
|
inscrire le comportement réel des joueurs, jusqu’à rendre l’histoire économique
|
||||||
|
du serveur lisible dans leur géologie. Le [cahier de cosmologie](cosmologie.md)
|
||||||
|
conserve le texte de l’auteur, ses exemples et les articulations encore à définir.
|
||||||
|
**Ces espaces, le shop et le ballast ne sont pas implémentés.** Le socle méta
|
||||||
|
des blocs pourra les alimenter ; il ne constitue pas encore un journal économique.
|
||||||
|
|
||||||
## Périmètre de départ
|
## Périmètre de départ
|
||||||
|
|
||||||
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
|
Le premier incrément porte sur le socle Fabric et la génération du monde : retrouver le générateur 26.2, produire une île principale dans un vrai vide, assurer une arrivée commune et sûre, puis poser une manière reproductible de générer des continents à une distance, une direction et une taille choisies.
|
||||||
@@ -260,7 +270,7 @@ déjà implémentée.
|
|||||||
ces connexions fait partie de l’exploration. Privilégier les installations
|
ces connexions fait partie de l’exploration. Privilégier les installations
|
||||||
encore utilisables et les chantiers interrompus pour raconter cette histoire.
|
encore utilisables et les chantiers interrompus pour raconter cette histoire.
|
||||||
Les back rooms de ces donjons sont des lieux physiques dans l’île, distincts
|
Les back rooms de ces donjons sont des lieux physiques dans l’île, distincts
|
||||||
de la dimension Backrooms recevant les objets perdus. Le futur butin combine
|
des futurs espaces Backrooms recevant les objets perdus. Le futur butin combine
|
||||||
des reliques collectives uniques et des récompenses personnelles ; les
|
des reliques collectives uniques et des récompenses personnelles ; les
|
||||||
spawners pourront rester exploitables après exploration sans régénérer les
|
spawners pourront rester exploitables après exploration sans régénérer les
|
||||||
donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux
|
donjons. Les règles précises restent à concevoir. Le réacteur et ces nouveaux
|
||||||
@@ -272,14 +282,20 @@ déjà implémentée.
|
|||||||
à construire ; voir [le contrat du prototype](testing-alpha17.md).
|
à construire ; voir [le contrat du prototype](testing-alpha17.md).
|
||||||
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
|
- **Secret rooms et challenge rooms** : des accès présents dans le monde mènent à des indoors aménagés, avec des équipements, des épreuves ou des gisements de fer, d'or, d'argent ou de titane.
|
||||||
|
|
||||||
### Dimensions
|
### Dimensions et espaces
|
||||||
|
|
||||||
| Dimension | Intention |
|
| Monde ou espace | Intention |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Cavernes** | Monde destiné au minage, avec des biomes souterrains, sans mineshafts ni parkings. Des cités anciennes particulièrement difficiles accueillent plusieurs types de squelettes et un nécromancien illageois qui les invoque. Il détient une boule de cristal. |
|
| **Cavernes** | Monde destiné au minage, avec des biomes souterrains, sans mineshafts ni parkings. Des cités anciennes particulièrement difficiles accueillent plusieurs types de squelettes et un nécromancien illageois qui les invoque. Il détient une boule de cristal. |
|
||||||
| **Alpha** | Monde inspiré de Minecraft Indev et des premières versions, avec les anciennes textures de Golden Days actives uniquement dans cette dimension. Notch y est un boss. |
|
| **Alpha** | Monde inspiré de Minecraft Indev et des premières versions, avec les anciennes textures de Golden Days actives uniquement dans cette dimension. Notch y est un boss. |
|
||||||
| **Backrooms** | Monde sombre à plusieurs niveaux, exploré avec une source de lumière. Les joueurs y arrivent dans un lit. Il reçoit dans des coffres les objets perdus dans le vide ou brûlés ; c'est aussi un territoire d'exploration et un lieu de quête. |
|
| **Backrooms** | Espaces artificiels orphelins dans la cosmologie, dont la géologie doit conserver l’histoire économique du serveur. L’intention antérieure d’un territoire sombre à plusieurs niveaux, accessible par le lit et recueillant des objets perdus, reste à articuler avec cette direction. |
|
||||||
| **Indoors** | Petits espaces associés à des objets, déclinés en classes de volume, avec une taille indicative allant de 5 × 5 × 5 à 100 × 100 × 100 blocs. Ils peuvent servir d'intérieurs, de salles secrètes ou de lieux d'événements. |
|
| **Indoors** | Espaces artificiels intentionnels, référencés et contrôlés : une porte peut mener à un intérieur plus grand que le bâtiment. L’auteur les décrit comme de petites dimensions instanciées. Les classes de volume envisagées vont indicativement de 5 × 5 × 5 à 100 × 100 × 100 blocs. |
|
||||||
|
|
||||||
|
Le [cahier de cosmologie](cosmologie.md#deux-devenirs-de-la-même-technologie)
|
||||||
|
distingue cette représentation narrative de son futur hébergement technique.
|
||||||
|
Un espace dit « orphelin » devra rester correctement identifié et sauvegardé
|
||||||
|
par Minecraft ; aucune perte réelle de référence ou corruption de chunks n’est
|
||||||
|
prévue. Backrooms et Indoors restent à implémenter.
|
||||||
|
|
||||||
La géométrie des indoors reste à décider : certains peuvent être des volumes précisément délimités, d'autres des espaces fermés qui se rebouclent en trois dimensions, de type tore 3D. Leurs objets d'accès sont liés à la mailbox de leur propriétaire et doivent être récupérables s'ils sont perdus. Les règles de propriété, de partage, de transfert et de sortie sûre seront définies avant leur implémentation.
|
La géométrie des indoors reste à décider : certains peuvent être des volumes précisément délimités, d'autres des espaces fermés qui se rebouclent en trois dimensions, de type tore 3D. Leurs objets d'accès sont liés à la mailbox de leur propriétaire et doivent être récupérables s'ils sont perdus. Les règles de propriété, de partage, de transfert et de sortie sûre seront définies avant leur implémentation.
|
||||||
|
|
||||||
@@ -303,6 +319,20 @@ La progression peut débloquer une table de fabrication dans l'inventaire, des c
|
|||||||
|
|
||||||
Les recettes de Just Enough Items sont révélées par paliers liés aux advancements. Les advancements Minecraft donnent accès aux recettes Minecraft ; ceux de Sanctuary ouvrent les blocs décoratifs, équipements et systèmes correspondants. Il faudra distinguer dans chaque ticket l'affichage des recettes, leur connaissance et l'autorisation réelle de fabriquer.
|
Les recettes de Just Enough Items sont révélées par paliers liés aux advancements. Les advancements Minecraft donnent accès aux recettes Minecraft ; ceux de Sanctuary ouvrent les blocs décoratifs, équipements et systèmes correspondants. Il faudra distinguer dans chaque ticket l'affichage des recettes, leur connaissance et l'autorisation réelle de fabriquer.
|
||||||
|
|
||||||
|
Le **Blocodex natif** fournit une mémoire personnelle des blocs : inconnus, vus,
|
||||||
|
minés, déjà possédés, jetés et posés, avec le stock porté actuel séparé de
|
||||||
|
l’historique. Cette base appartient à Sanctuary et reste disponible sans
|
||||||
|
appareil à fabriquer. Les palettes de construction, recherches ou objectifs
|
||||||
|
pourront consulter ces informations côté serveur ; leurs conditions et
|
||||||
|
récompenses seront définies par leurs propres tickets. Le socle alpha.23 et
|
||||||
|
ses limites sont décrits dans [Blocodex](blocodex.md). Ce socle distingue aussi
|
||||||
|
les blocs du terrain chargé des objets en inventaires et stockages, avec des
|
||||||
|
relevés de couverture séparés. Un historique global par jour UTC conserve les
|
||||||
|
actions matérielles observées depuis l’installation. Ces données pourront
|
||||||
|
servir aux palettes, au shop, aux expansions et à la géologie historique des
|
||||||
|
Backrooms ; elles ne définissent pas encore les coûts, transactions ou règles
|
||||||
|
de ballast de ces systèmes.
|
||||||
|
|
||||||
### Construction et production
|
### Construction et production
|
||||||
|
|
||||||
- **Mining** améliore la vitesse de minage et ouvre le vein mining : extraction de blocs d'une même veine ou sur un plan défini par l'orientation du joueur.
|
- **Mining** améliore la vitesse de minage et ouvre le vein mining : extraction de blocs d'une même veine ou sur un plan défini par l'orientation du joueur.
|
||||||
|
|||||||
+2
-2
@@ -8,6 +8,6 @@ loader_version=0.19.5
|
|||||||
loom_version=1.17.20
|
loom_version=1.17.20
|
||||||
fabric_api_version=0.160.0+26.3
|
fabric_api_version=0.160.0+26.3
|
||||||
|
|
||||||
mod_version=0.1.0-alpha.22
|
mod_version=0.1.0-alpha.23
|
||||||
pack_version=0.1.0-alpha.22
|
pack_version=0.1.0-alpha.23
|
||||||
maven_group=fr.koka.sanctuary
|
maven_group=fr.koka.sanctuary
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ tasks.named('check') { dependsOn('hydrologySmoke') }
|
|||||||
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
|
// src/test contains a JavaExec assertion harness; engine tests use Fabric's gametest source set.
|
||||||
tasks.named('test') { failOnNoDiscoveredTests = false }
|
tasks.named('test') { failOnNoDiscoveredTests = false }
|
||||||
|
|
||||||
|
tasks.register('blockKnowledgeSmoke', JavaExec) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Check personal block history, atomic persistence and corrupt-file refusal.'
|
||||||
|
dependsOn('testClasses')
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'fr.koka.sanctuary.knowledge.BlockKnowledgeSmoke'
|
||||||
|
}
|
||||||
|
tasks.named('check') { dependsOn('blockKnowledgeSmoke') }
|
||||||
|
|
||||||
|
tasks.register('materialActivitySmoke', JavaExec) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Check dated material observations, daily isolation and atomic persistence.'
|
||||||
|
dependsOn('testClasses')
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'fr.koka.sanctuary.knowledge.history.MaterialActivitySmoke'
|
||||||
|
}
|
||||||
|
tasks.named('check') { dependsOn('materialActivitySmoke') }
|
||||||
|
|
||||||
tasks.register('naturalHydrologySmoke', JavaExec) {
|
tasks.register('naturalHydrologySmoke', JavaExec) {
|
||||||
group = 'verification'
|
group = 'verification'
|
||||||
description = 'Check broad shores, supported sediments and natural rock springs.'
|
description = 'Check broad shores, supported sediments and natural rock springs.'
|
||||||
|
|||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import fr.koka.sanctuary.client.SanctuaryBlocodexScreen;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledge;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||||
|
import net.minecraft.client.gui.components.AbstractWidget;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.CycleButton;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.ObjectSelectionList;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.client.input.KeyEvent;
|
||||||
|
import net.minecraft.core.RegistryAccess;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Native rendering with an explicit test fixture; no world or server state is fabricated. */
|
||||||
|
final class BlocodexClientChecks {
|
||||||
|
private static final KeyEvent PRESS = new KeyEvent(InputConstants.KEY_RETURN, 0, 0);
|
||||||
|
private static final String LARGE_COUNTER_ID = "minecraft:ancient_debris";
|
||||||
|
private static final String REMOVED_MOD_ID = "fixture_missing_mod:retained_block";
|
||||||
|
|
||||||
|
static void run(ClientGameTestContext context) {
|
||||||
|
Screen previous = context.computeOnClient(client -> client.gui.screen());
|
||||||
|
int previousScale = context.computeOnClient(client -> client.options.guiScale().get());
|
||||||
|
List<BlockKnowledge> fixture = context.computeOnClient(client -> fixture());
|
||||||
|
List<Map<String, Object>> screenshots = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
client.options.guiScale().set(1);
|
||||||
|
client.resizeGui();
|
||||||
|
client.gui.setScreen(new SanctuaryBlocodexScreen());
|
||||||
|
});
|
||||||
|
SanctuaryBlocodexScreen screen = context.computeOnClient(client -> (SanctuaryBlocodexScreen) client.gui.screen());
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
require(list(screen).children().isEmpty(), "No connection must not invent unknown block rows");
|
||||||
|
fixtureRequest(screen, 401);
|
||||||
|
screen.accept(wireRoundTrip(part(fixture, 999, 0)));
|
||||||
|
require(list(screen).children().isEmpty(), "A stale request must not populate the screen");
|
||||||
|
screen.accept(wireRoundTrip(part(fixture, 401, 0)));
|
||||||
|
require(list(screen).children().isEmpty(), "An incomplete snapshot must not reveal partial knowledge");
|
||||||
|
for (int index = 1; index < partCount(fixture); index++) {
|
||||||
|
screen.accept(wireRoundTrip(part(fixture, 401, index)));
|
||||||
|
}
|
||||||
|
require(filteredCount(screen) == fixture.size(), "The full snapshot must become visible together");
|
||||||
|
verifyControls(screen, fixture);
|
||||||
|
assertWidgetBounds(screen);
|
||||||
|
});
|
||||||
|
context.waitTick();
|
||||||
|
Path large = context.takeScreenshot("sanctuary-blocodex-fixture-large");
|
||||||
|
screenshots.add(context.computeOnClient(client -> Map.of("file", large.toString(),
|
||||||
|
"gui_width", screen.width, "gui_height", screen.height, "gui_scale", client.getWindow().getGuiScale())));
|
||||||
|
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
// Vanilla auto scale chooses its largest permitted scale and therefore its smallest GUI.
|
||||||
|
client.options.guiScale().set(0);
|
||||||
|
client.resizeGui();
|
||||||
|
require(client.gui.screen() == screen, "Changing GUI scale must retain the Blocodex");
|
||||||
|
require(filteredCount(screen) == fixture.size(), "Resizing must retain the loaded catalogue");
|
||||||
|
assertWidgetBounds(screen);
|
||||||
|
verifyControls(screen, fixture);
|
||||||
|
});
|
||||||
|
context.waitTick();
|
||||||
|
Path small = context.takeScreenshot("sanctuary-blocodex-fixture-minimum-gui");
|
||||||
|
screenshots.add(context.computeOnClient(client -> Map.of("file", small.toString(),
|
||||||
|
"gui_width", screen.width, "gui_height", screen.height, "gui_scale", client.getWindow().getGuiScale())));
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
search(screen).setValue(LARGE_COUNTER_ID);
|
||||||
|
require(list(screen).children().size() == 1, "Counter fixture must be reachable by identifier");
|
||||||
|
String narration = list(screen).children().getFirst().getNarration().getString();
|
||||||
|
require(narration.contains(Long.toString(Long.MAX_VALUE))
|
||||||
|
&& narration.contains(Long.toString(Long.MAX_VALUE - 1))
|
||||||
|
&& narration.contains(Long.toString(Long.MAX_VALUE - 2))
|
||||||
|
&& narration.contains(Integer.toString(Integer.MAX_VALUE)),
|
||||||
|
"Exact counters must remain available in narration without overflow or truncation");
|
||||||
|
});
|
||||||
|
context.waitTick();
|
||||||
|
context.takeScreenshot("sanctuary-blocodex-fixture-large-counters");
|
||||||
|
|
||||||
|
context.clickScreenButton("sanctuary.blocodex.help");
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
require(client.gui.screen() != screen, "Help must open an actual readable screen");
|
||||||
|
assertWidgetBounds(client.gui.screen());
|
||||||
|
});
|
||||||
|
context.waitTick();
|
||||||
|
context.takeScreenshot("sanctuary-blocodex-help-minimum-gui");
|
||||||
|
context.clickScreenButton("gui.back");
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
require(client.gui.screen() == screen, "Closing help must return to the same Blocodex");
|
||||||
|
require(search(screen).getValue().equals(LARGE_COUNTER_ID), "Help must retain the search");
|
||||||
|
verifyFragmentFailures(screen, fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
Path diagnostics = Path.of("diagnostics").toAbsolutePath();
|
||||||
|
Files.createDirectories(diagnostics);
|
||||||
|
Files.writeString(diagnostics.resolve("sanctuary-blocodex-client.json"),
|
||||||
|
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||||
|
"fixture", "Explicit client test snapshot; no server or saved world is used",
|
||||||
|
"entries", fixture.size(), "fragments", partCount(fixture),
|
||||||
|
"search_filters_pagination", true, "exact_counter_narration", true,
|
||||||
|
"fragment_atomicity_and_stale_requests", true, "invalid_snapshots_clear_state", true,
|
||||||
|
"screenshots", screenshots, "passed", true)) + "\n");
|
||||||
|
SanctuaryMod.LOGGER.info("Blocodex native client checks passed: explicit fixture, real widgets, two GUI scales, exact counters and fragment atomicity.");
|
||||||
|
} catch (IOException failure) {
|
||||||
|
throw new AssertionError("Unable to write Blocodex client diagnostics", failure);
|
||||||
|
} finally {
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
client.gui.setScreen(previous);
|
||||||
|
client.options.guiScale().set(previousScale);
|
||||||
|
client.resizeGui();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BlockKnowledge> fixture() {
|
||||||
|
List<BlockKnowledge> entries = new ArrayList<>();
|
||||||
|
entries.add(new BlockKnowledge(LARGE_COUNTER_ID, true, true,
|
||||||
|
Long.MAX_VALUE, Long.MAX_VALUE - 1, Long.MAX_VALUE - 2, Integer.MAX_VALUE));
|
||||||
|
List<String> ids = BuiltInRegistries.BLOCK.stream()
|
||||||
|
.filter(block -> block.asItem() != Items.AIR)
|
||||||
|
.map(block -> BuiltInRegistries.BLOCK.getKey(block).toString())
|
||||||
|
.filter(id -> !id.equals(LARGE_COUNTER_ID)).sorted().limit(258).toList();
|
||||||
|
require(ids.size() == 258, "The native registry must contain enough blocks to exercise three fragments");
|
||||||
|
for (int index = 0; index < ids.size(); index++) {
|
||||||
|
int evidence = index % 7;
|
||||||
|
entries.add(new BlockKnowledge(ids.get(index), evidence == 0, evidence == 1,
|
||||||
|
evidence == 2 ? 7 : 0, evidence == 3 ? 11 : 0,
|
||||||
|
evidence == 4 ? 13 : 0, evidence == 5 ? 17 : 0));
|
||||||
|
}
|
||||||
|
entries.add(new BlockKnowledge(REMOVED_MOD_ID, false, true, 0, 0, 0, 0));
|
||||||
|
return List.copyOf(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifyControls(SanctuaryBlocodexScreen screen, List<BlockKnowledge> fixture) {
|
||||||
|
search(screen).setValue(REMOVED_MOD_ID.toUpperCase(java.util.Locale.ROOT));
|
||||||
|
require(filteredCount(screen) == 1 && list(screen).children().size() == 1,
|
||||||
|
"Identifier search must ignore case and preserve absent mod identifiers");
|
||||||
|
search(screen).setValue("fixture_no_matching_block");
|
||||||
|
require(filteredCount(screen) == 0 && list(screen).children().isEmpty()
|
||||||
|
&& !button(screen, "<").active && !button(screen, ">").active,
|
||||||
|
"Empty search results must not retain a previous page or active pagination");
|
||||||
|
search(screen).setValue("");
|
||||||
|
for (var filter : Map.<String, Predicate<BlockKnowledge>>of(
|
||||||
|
"UNKNOWN", entry -> !entry.known(), "KNOWN", BlockKnowledge::known,
|
||||||
|
"SEEN", BlockKnowledge::seen, "POSSESSED", BlockKnowledge::possessed,
|
||||||
|
"MINED", entry -> entry.mined() > 0, "DROPPED", entry -> entry.dropped() > 0,
|
||||||
|
"PLACED", entry -> entry.placed() > 0, "CARRIED", entry -> entry.carried() > 0).entrySet()) {
|
||||||
|
chooseFilter(screen, filter.getKey());
|
||||||
|
require(filteredCount(screen) == fixture.stream().filter(filter.getValue()).count(),
|
||||||
|
"Facet " + filter.getKey() + " must use only its own evidence");
|
||||||
|
}
|
||||||
|
chooseFilter(screen, "ALL");
|
||||||
|
require(filteredCount(screen) == fixture.size(), "All filter must restore the whole catalogue");
|
||||||
|
List<String> first = narrations(screen);
|
||||||
|
require(!first.isEmpty() && button(screen, ">").active && !button(screen, "<").active,
|
||||||
|
"A full catalogue must expose another page while starting on page one");
|
||||||
|
button(screen, ">").onPress(PRESS);
|
||||||
|
require(!narrations(screen).equals(first) && button(screen, "<").active,
|
||||||
|
"Next page must replace the visible rows and enable return");
|
||||||
|
button(screen, "<").onPress(PRESS);
|
||||||
|
require(narrations(screen).equals(first), "Returning to the first page must restore its exact rows");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifyFragmentFailures(SanctuaryBlocodexScreen screen, List<BlockKnowledge> fixture) {
|
||||||
|
search(screen).setValue("");
|
||||||
|
List<String> before = narrations(screen);
|
||||||
|
fixtureRequest(screen, 402);
|
||||||
|
screen.accept(part(fixture, 402, 0));
|
||||||
|
screen.accept(part(fixture, 401, 1));
|
||||||
|
require(narrations(screen).equals(before) && filteredCount(screen) == fixture.size(),
|
||||||
|
"An incomplete refresh and stale fragment must retain only the prior complete snapshot");
|
||||||
|
screen.accept(part(fixture, 402, 2));
|
||||||
|
require(list(screen).children().isEmpty() && filteredCount(screen) == 0,
|
||||||
|
"Out-of-order fragments must invalidate the view without inventing zero counters");
|
||||||
|
|
||||||
|
fixtureRequest(screen, 403);
|
||||||
|
List<BlockKnowledge> duplicate = new ArrayList<>(fixture);
|
||||||
|
duplicate.set(128, duplicate.getFirst());
|
||||||
|
screen.accept(part(duplicate, 403, 0));
|
||||||
|
screen.accept(part(duplicate, 403, 1));
|
||||||
|
require(list(screen).children().isEmpty(), "Duplicate block identifiers across fragments must never publish");
|
||||||
|
|
||||||
|
fixtureRequest(screen, 404);
|
||||||
|
screen.accept(new BlockKnowledgePayloads.SnapshotPart(404, 0, 1, 0, false, List.of()));
|
||||||
|
require(list(screen).children().isEmpty() && !(boolean) read(screen, "available"),
|
||||||
|
"An unhealthy server snapshot must remain unavailable rather than become an unknown catalogue");
|
||||||
|
fixtureRequest(screen, 405);
|
||||||
|
for (int index = 0; index < partCount(fixture); index++) screen.accept(part(fixture, 405, index));
|
||||||
|
require(filteredCount(screen) == fixture.size(), "A complete fresh request must recover after a failed snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockKnowledgePayloads.SnapshotPart part(List<BlockKnowledge> entries, int request, int index) {
|
||||||
|
int start = index * BlockKnowledgePayloads.FRAGMENT_SIZE;
|
||||||
|
return new BlockKnowledgePayloads.SnapshotPart(request, index, partCount(entries), entries.size(), true,
|
||||||
|
entries.subList(start, Math.min(entries.size(), start + BlockKnowledgePayloads.FRAGMENT_SIZE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int partCount(List<BlockKnowledge> entries) {
|
||||||
|
return Math.max(1, (entries.size() + BlockKnowledgePayloads.FRAGMENT_SIZE - 1) / BlockKnowledgePayloads.FRAGMENT_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockKnowledgePayloads.SnapshotPart wireRoundTrip(BlockKnowledgePayloads.SnapshotPart part) {
|
||||||
|
RegistryFriendlyByteBuf buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), RegistryAccess.EMPTY);
|
||||||
|
try {
|
||||||
|
BlockKnowledgePayloads.SnapshotPart.CODEC.encode(buffer, part);
|
||||||
|
var decoded = BlockKnowledgePayloads.SnapshotPart.CODEC.decode(buffer);
|
||||||
|
require(decoded.equals(part) && buffer.readableBytes() == 0,
|
||||||
|
"The real payload codec must retain exact counters, flags and fragment boundaries");
|
||||||
|
return decoded;
|
||||||
|
} finally {
|
||||||
|
buffer.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fixtureRequest(SanctuaryBlocodexScreen screen, int request) {
|
||||||
|
// Only tests bypass the live connection prerequisite. Production has no fixture entry point.
|
||||||
|
write(screen, "activeRequest", request);
|
||||||
|
write(screen, "nextPart", 0);
|
||||||
|
write(screen, "expectedParts", -1);
|
||||||
|
write(screen, "expectedTotal", -1);
|
||||||
|
write(screen, "pending", true);
|
||||||
|
write(screen, "lastRequestTick", 1_000_000); // Keep screenshots independent of offline auto-refresh.
|
||||||
|
((List<?>) read(screen, "incoming")).clear();
|
||||||
|
((java.util.Set<?>) read(screen, "incomingIds")).clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int filteredCount(SanctuaryBlocodexScreen screen) {
|
||||||
|
return ((List<?>) read(screen, "filtered")).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> narrations(SanctuaryBlocodexScreen screen) {
|
||||||
|
return list(screen).children().stream().map(entry -> entry.getNarration().getString()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectSelectionList<?> list(Screen screen) {
|
||||||
|
return screen.children().stream().filter(ObjectSelectionList.class::isInstance)
|
||||||
|
.map(child -> (ObjectSelectionList<?>) child).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EditBox search(Screen screen) {
|
||||||
|
return screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button button(Screen screen, String text) {
|
||||||
|
return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
|
||||||
|
.filter(button -> button.getMessage().getString().equals(text)).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void chooseFilter(Screen screen, String target) {
|
||||||
|
CycleButton<?> button = screen.children().stream().filter(CycleButton.class::isInstance)
|
||||||
|
.map(CycleButton.class::cast).findFirst().orElseThrow();
|
||||||
|
int attempts = 9;
|
||||||
|
while (!button.getValue().toString().equals(target) && attempts-- > 0) button.onPress(PRESS);
|
||||||
|
require(button.getValue().toString().equals(target), "Filter cycle must expose " + target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertWidgetBounds(Screen screen) {
|
||||||
|
for (var child : screen.children()) {
|
||||||
|
if (child instanceof AbstractWidget widget) {
|
||||||
|
require(widget.getX() >= 0 && widget.getY() >= 0
|
||||||
|
&& widget.getX() + widget.getWidth() <= screen.width
|
||||||
|
&& widget.getY() + widget.getHeight() <= screen.height,
|
||||||
|
"Widget must remain inside the native GUI: " + widget.getMessage().getString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object read(Object target, String name) {
|
||||||
|
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(target); }
|
||||||
|
catch (ReflectiveOperationException failure) { throw new AssertionError("Missing client fixture field " + name, failure); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(Object target, String name, Object value) {
|
||||||
|
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); field.set(target, value); }
|
||||||
|
catch (ReflectiveOperationException failure) { throw new AssertionError("Missing client fixture field " + name, failure); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import fr.koka.sanctuary.client.SanctuaryBlocodexScreen;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledge;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||||
|
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
|
||||||
|
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.ObjectSelectionList;
|
||||||
|
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||||
|
import net.minecraft.client.input.KeyEvent;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
|
||||||
|
/** Real key mapping, connection and server observations in a newly created development world. */
|
||||||
|
final class BlocodexNetworkClientChecks {
|
||||||
|
private static final String SEEN_ID = "minecraft:gold_block";
|
||||||
|
private static final String POSSESSED_ID = "minecraft:diamond_block";
|
||||||
|
private static final String UNKNOWN_ID = "minecraft:emerald_block";
|
||||||
|
|
||||||
|
static void run(ClientGameTestContext context) {
|
||||||
|
Path runDirectory = Path.of("").toAbsolutePath().normalize();
|
||||||
|
Path saves = context.computeOnClient(client -> client.getLevelSource().getBaseDir().toAbsolutePath().normalize());
|
||||||
|
require(runDirectory.endsWith(Path.of("build", "run", "clientGameTest"))
|
||||||
|
&& saves.startsWith(runDirectory),
|
||||||
|
"The network test must create worlds only inside the ignored development client run directory");
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
var matches = Arrays.stream(client.options.keyMappings)
|
||||||
|
.filter(key -> key.getName().equals("sanctuary.blocodex.key")).toList();
|
||||||
|
require(matches.size() == 1, "Exactly one innate Blocodex key mapping must be registered");
|
||||||
|
var key = matches.getFirst();
|
||||||
|
require(key.getCategory().id().equals(SanctuaryMod.id("sanctuary"))
|
||||||
|
&& key.matches(new KeyEvent(InputConstants.KEY_B, 0, 0)),
|
||||||
|
"The Sanctuary category must expose the real B binding in the development client");
|
||||||
|
});
|
||||||
|
|
||||||
|
String worldName = "Sanctuary Blocodex network " + UUID.randomUUID();
|
||||||
|
Path saveDirectory;
|
||||||
|
UUID playerId;
|
||||||
|
Map<String, BlockKnowledge> firstServerSnapshot;
|
||||||
|
Map<String, BlockKnowledge> secondServerSnapshot;
|
||||||
|
// Fabric's consistent defaults are a fresh flat world, seed 1, no structures or mobs.
|
||||||
|
try (var world = context.worldBuilder().adjustSettings(settings -> {
|
||||||
|
settings.setName(worldName);
|
||||||
|
settings.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
|
||||||
|
settings.setAllowCommands(false);
|
||||||
|
}).create()) {
|
||||||
|
saveDirectory = world.getWorldSave().getSaveDirectory().toAbsolutePath().normalize();
|
||||||
|
require(saveDirectory.startsWith(saves) && !saveDirectory.equals(saves),
|
||||||
|
"The new test save must remain inside the development saves directory");
|
||||||
|
var connection = world.getConnection();
|
||||||
|
var serverContext = world.getServer();
|
||||||
|
connection.waitForChunksDownload(1200);
|
||||||
|
context.waitFor(client -> client.player != null && client.gui.screen() == null
|
||||||
|
&& ClientPlayNetworking.canSend(BlockKnowledgePayloads.Request.TYPE), 600);
|
||||||
|
playerId = serverContext.computeOnServer(server -> player(server).getUUID());
|
||||||
|
serverContext.runOnServer(server -> {
|
||||||
|
require(!BlockKnowledgeService.knowledge(player(server), Blocks.GOLD_BLOCK).known(),
|
||||||
|
"A fresh flat world must not already know the observation target");
|
||||||
|
require(!BlockKnowledgeService.knowledge(player(server), Blocks.DIAMOND_BLOCK).known(),
|
||||||
|
"A fresh player must not already possess the inventory target");
|
||||||
|
});
|
||||||
|
|
||||||
|
BlockPos target = serverContext.computeOnServer(server -> {
|
||||||
|
ServerPlayer player = player(server);
|
||||||
|
BlockPos pos = player.blockPosition().offset(0, 1, 3);
|
||||||
|
// Explicit test setup; normal tick observation must discover both facts itself.
|
||||||
|
player.level().setBlockAndUpdate(pos, Blocks.GOLD_BLOCK.defaultBlockState());
|
||||||
|
player.getInventory().setItem(0, new ItemStack(Items.DIAMOND_BLOCK, 3));
|
||||||
|
player.inventoryMenu.broadcastChanges();
|
||||||
|
return pos;
|
||||||
|
});
|
||||||
|
connection.waitForClientboundPackets();
|
||||||
|
context.waitFor(client -> client.level.getBlockState(target).is(Blocks.GOLD_BLOCK)
|
||||||
|
&& client.player.getInventory().getItem(0).is(Items.DIAMOND_BLOCK), 600);
|
||||||
|
context.getInput().lookAt(target);
|
||||||
|
connection.waitForServerboundPackets();
|
||||||
|
context.waitTicks(BlockKnowledgeService.OBSERVATION_INTERVAL + 1);
|
||||||
|
serverContext.waitFor(server -> {
|
||||||
|
ServerPlayer player = player(server);
|
||||||
|
var observed = BlockKnowledgeService.knowledge(player, Blocks.GOLD_BLOCK);
|
||||||
|
var carried = BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_BLOCK);
|
||||||
|
return observed.seen() && carried.possessed() && carried.carried() == 3;
|
||||||
|
}, 600);
|
||||||
|
firstServerSnapshot = expected(serverContext);
|
||||||
|
require(firstServerSnapshot.get(SEEN_ID).seen()
|
||||||
|
&& !firstServerSnapshot.get(SEEN_ID).possessed()
|
||||||
|
&& !firstServerSnapshot.get(POSSESSED_ID).seen()
|
||||||
|
&& firstServerSnapshot.get(POSSESSED_ID).carried() == 3
|
||||||
|
&& !firstServerSnapshot.get(UNKNOWN_ID).known(),
|
||||||
|
"The live server must retain independent seen, possessed and unknown states");
|
||||||
|
|
||||||
|
SanctuaryBlocodexScreen first = openWithB(context);
|
||||||
|
context.runOnClient(client -> assertSnapshot(first, firstServerSnapshot));
|
||||||
|
context.runOnClient(client -> search(first).setValue(POSSESSED_ID));
|
||||||
|
context.waitTick();
|
||||||
|
context.takeScreenshot("sanctuary-blocodex-network-carried-three");
|
||||||
|
context.getInput().pressKey(InputConstants.KEY_ESCAPE);
|
||||||
|
context.waitFor(client -> client.gui.screen() == null, 120);
|
||||||
|
|
||||||
|
serverContext.runOnServer(server -> {
|
||||||
|
ServerPlayer player = player(server);
|
||||||
|
player.getInventory().setItem(0, ItemStack.EMPTY);
|
||||||
|
player.inventoryMenu.broadcastChanges();
|
||||||
|
});
|
||||||
|
context.waitTicks(2);
|
||||||
|
serverContext.waitFor(server -> {
|
||||||
|
var knowledge = BlockKnowledgeService.knowledge(player(server), Blocks.DIAMOND_BLOCK);
|
||||||
|
return knowledge.possessed() && knowledge.carried() == 0;
|
||||||
|
}, 600);
|
||||||
|
connection.waitForClientboundPackets();
|
||||||
|
secondServerSnapshot = expected(serverContext);
|
||||||
|
|
||||||
|
// The production request limiter uses wall time, while game-test ticks can run faster.
|
||||||
|
long reopenAt = System.nanoTime() + 1_100_000_000L;
|
||||||
|
context.waitFor(client -> System.nanoTime() >= reopenAt, 2400);
|
||||||
|
SanctuaryBlocodexScreen second = openWithB(context);
|
||||||
|
require(second != first, "Closing and reopening with B must issue a fresh screen request");
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
assertSnapshot(second, secondServerSnapshot);
|
||||||
|
search(second).setValue(POSSESSED_ID);
|
||||||
|
require(list(second).children().size() == 1,
|
||||||
|
"The received block must remain searchable after its current inventory becomes empty");
|
||||||
|
});
|
||||||
|
context.waitTick();
|
||||||
|
context.takeScreenshot("sanctuary-blocodex-network-sticky-possession");
|
||||||
|
context.getInput().pressKey(InputConstants.KEY_ESCAPE);
|
||||||
|
context.waitFor(client -> client.gui.screen() == null, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path sidecar = saveDirectory.resolve("data/sanctuary-blocodex").resolve(playerId + ".json");
|
||||||
|
require(Files.isRegularFile(sidecar), "Closing the real server must persist its observed player history");
|
||||||
|
Path diagnostics = runDirectory.resolve("diagnostics");
|
||||||
|
Files.createDirectories(diagnostics);
|
||||||
|
Files.writeString(diagnostics.resolve("sanctuary-blocodex-network-client.json"),
|
||||||
|
new GsonBuilder().setPrettyPrinting().create().toJson(Map.of(
|
||||||
|
"fixture", "New flat development world; gold block and three diamond blocks prepared on the server",
|
||||||
|
"save_directory", saveDirectory.toString(), "player_uuid", playerId.toString(),
|
||||||
|
"real_b_key_and_payloads", true, "server_gaze_and_inventory", true,
|
||||||
|
"before_removal", firstServerSnapshot, "after_removal", secondServerSnapshot,
|
||||||
|
"sidecar_saved_on_close", true, "passed", true)) + "\n");
|
||||||
|
SanctuaryMod.LOGGER.info("Blocodex network client checks passed: real B key, native server snapshots, observed gaze, sticky possession and empty carried stock in {}", saveDirectory);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
throw new AssertionError("Unable to verify native Blocodex network diagnostics", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SanctuaryBlocodexScreen openWithB(ClientGameTestContext context) {
|
||||||
|
context.getInput().pressKey(InputConstants.KEY_B);
|
||||||
|
context.waitForScreen(SanctuaryBlocodexScreen.class);
|
||||||
|
context.waitFor(client -> client.gui.screen() instanceof SanctuaryBlocodexScreen screen
|
||||||
|
&& (boolean) read(screen, "loaded"), 1200);
|
||||||
|
return context.computeOnClient(client -> (SanctuaryBlocodexScreen) client.gui.screen());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, BlockKnowledge> expected(TestServerContext context) {
|
||||||
|
return context.computeOnServer(server -> {
|
||||||
|
ServerPlayer player = player(server);
|
||||||
|
return Map.of(SEEN_ID, BlockKnowledgeService.knowledge(player, Blocks.GOLD_BLOCK),
|
||||||
|
POSSESSED_ID, BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_BLOCK),
|
||||||
|
UNKNOWN_ID, BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertSnapshot(SanctuaryBlocodexScreen screen, Map<String, BlockKnowledge> expected) {
|
||||||
|
require((boolean) read(screen, "available"), "The real server snapshot must be available");
|
||||||
|
List<?> rows = (List<?>) read(screen, "catalogue");
|
||||||
|
Map<String, BlockKnowledge> received = rows.stream().map(BlocodexNetworkClientChecks::knowledge)
|
||||||
|
.collect(Collectors.toUnmodifiableMap(BlockKnowledge::blockId, value -> value));
|
||||||
|
for (var entry : expected.entrySet()) {
|
||||||
|
require(entry.getValue().equals(received.get(entry.getKey())),
|
||||||
|
"The live network snapshot must retain the server's exact evidence and counters for " + entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockKnowledge knowledge(Object row) {
|
||||||
|
try {
|
||||||
|
Method method = row.getClass().getDeclaredMethod("knowledge");
|
||||||
|
method.setAccessible(true);
|
||||||
|
return (BlockKnowledge) method.invoke(row);
|
||||||
|
} catch (ReflectiveOperationException failure) {
|
||||||
|
throw new AssertionError("Unable to read received Blocodex row", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerPlayer player(MinecraftServer server) {
|
||||||
|
List<ServerPlayer> players = server.getPlayerList().getPlayers();
|
||||||
|
require(players.size() == 1, "The development integrated server must have one actual connected player");
|
||||||
|
return players.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EditBox search(SanctuaryBlocodexScreen screen) {
|
||||||
|
return screen.children().stream().filter(EditBox.class::isInstance)
|
||||||
|
.map(EditBox.class::cast).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectSelectionList<?> list(SanctuaryBlocodexScreen screen) {
|
||||||
|
return screen.children().stream().filter(ObjectSelectionList.class::isInstance)
|
||||||
|
.map(child -> (ObjectSelectionList<?>) child).findFirst().orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object read(Object target, String name) {
|
||||||
|
try { Field field = target.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(target); }
|
||||||
|
catch (ReflectiveOperationException failure) { throw new AssertionError("Unable to read received Blocodex state " + name, failure); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -17,6 +17,8 @@ public final class SanctuaryClientRenderTests implements FabricClientGameTest {
|
|||||||
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
|
SanctuaryMod.LOGGER.info("Client render tests passed: Sanctuary sky and fog mixins applied.");
|
||||||
});
|
});
|
||||||
SanctuaryWorldOptionsClientChecks.run(context);
|
SanctuaryWorldOptionsClientChecks.run(context);
|
||||||
|
BlocodexClientChecks.run(context);
|
||||||
|
BlocodexNetworkClientChecks.run(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertApplied(ClassLoader loader, String target, String hook) {
|
private static void assertApplied(ClassLoader loader, String target, String hook) {
|
||||||
|
|||||||
+384
@@ -0,0 +1,384 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledge;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeEvents;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeFacet;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeStore;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.network.Connection;
|
||||||
|
import net.minecraft.network.protocol.PacketFlow;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.server.network.CommonListenerCookie;
|
||||||
|
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||||
|
import net.minecraft.stats.Stats;
|
||||||
|
import net.minecraft.util.Prediction;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.SimpleContainer;
|
||||||
|
import net.minecraft.world.inventory.ChestMenu;
|
||||||
|
import net.minecraft.world.inventory.ContainerInput;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.item.context.UseOnContext;
|
||||||
|
import net.minecraft.world.level.GameType;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/** Exercise native actions so invalid placements and death drops cannot unlock false history. */
|
||||||
|
public final class BlockKnowledgeGameTests {
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void placementCountsConfirmedStatesAndRejectsFailure(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
|
||||||
|
helper.getLevel().setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
|
||||||
|
helper.getLevel().setBlockAndUpdate(support.east(), Blocks.AIR.defaultBlockState());
|
||||||
|
|
||||||
|
helper.assertTrue(place(player, Items.COBBLESTONE, support, Direction.UP),
|
||||||
|
"A valid block placement must succeed");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.COBBLESTONE).placed() == 1,
|
||||||
|
"A successful placement must be recorded exactly once");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.COBBLESTONE).possessed(),
|
||||||
|
"The final held block must be observed before placement consumes it");
|
||||||
|
|
||||||
|
helper.assertTrue(place(player, Items.TORCH, support, Direction.EAST),
|
||||||
|
"A torch must attach to the side of a solid support");
|
||||||
|
helper.assertTrue(helper.getLevel().getBlockState(support.east()).is(Blocks.WALL_TORCH),
|
||||||
|
"The placement fixture must produce a wall torch");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.WALL_TORCH).placed() == 1,
|
||||||
|
"Placement history must use the actual wall block");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.TORCH).placed() == 0,
|
||||||
|
"The item form must not receive a second placement");
|
||||||
|
|
||||||
|
BlockPos invalidSupport = helper.absolutePos(new BlockPos(3, 1, 1));
|
||||||
|
helper.getLevel().setBlockAndUpdate(invalidSupport, Blocks.STONE.defaultBlockState());
|
||||||
|
helper.getLevel().setBlockAndUpdate(invalidSupport.above(), Blocks.AIR.defaultBlockState());
|
||||||
|
helper.assertTrue(!place(player, Items.OAK_SAPLING, invalidSupport, Direction.UP),
|
||||||
|
"A sapling cannot be placed on stone");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.OAK_SAPLING).placed() == 0,
|
||||||
|
"A failed item use must not count as a placement");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void vanillaMiningAndVoluntaryDropsRemainAuthoritative(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
BlockPos mined = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlockAndUpdate(mined, Blocks.STONE.defaultBlockState());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIAMOND_PICKAXE));
|
||||||
|
helper.assertTrue(player.gameMode.destroyBlock(mined), "The test player must mine stone");
|
||||||
|
helper.assertTrue(player.getStats().getValue(Stats.BLOCK_MINED.get(Blocks.STONE)) == 1,
|
||||||
|
"The successful harvest must increment Minecraft's mined counter");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).mined() == 1,
|
||||||
|
"Sanctuary must expose Minecraft's counter without double counting");
|
||||||
|
|
||||||
|
helper.getLevel().setBlockAndUpdate(mined, Blocks.STONE.defaultBlockState());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
|
||||||
|
player.gameMode.destroyBlock(mined);
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).mined() == 1,
|
||||||
|
"Breaking stone with the wrong tool must preserve vanilla mining semantics");
|
||||||
|
|
||||||
|
player.drop(new ItemStack(Items.DIRT, 3), true, Prediction.PREDICTED);
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.DIRT).dropped() == 3,
|
||||||
|
"Voluntary drops must count individual items exactly once");
|
||||||
|
player.getInventory().setItem(0, new ItemStack(Items.DIRT, 5));
|
||||||
|
player.getInventory().dropAll();
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.DIRT).dropped() == 3,
|
||||||
|
"The inventory's death-drop path must not count as voluntary drops");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void cursorPossessionSurvivesImmediateReturnToChest(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
SimpleContainer chest = new SimpleContainer(27);
|
||||||
|
chest.setItem(0, new ItemStack(Items.EMERALD_BLOCK, 5));
|
||||||
|
ChestMenu menu = ChestMenu.threeRows(1, player.getInventory(), chest);
|
||||||
|
player.containerMenu = menu;
|
||||||
|
helper.assertTrue(!BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK).possessed(),
|
||||||
|
"An unopened chest stack must not count as player possession");
|
||||||
|
|
||||||
|
// No service query or tick between taking and returning the stack.
|
||||||
|
menu.clicked(0, 0, ContainerInput.PICKUP, player);
|
||||||
|
menu.clicked(0, 0, ContainerInput.PICKUP, player);
|
||||||
|
var knowledge = BlockKnowledgeService.knowledge(player, Blocks.EMERALD_BLOCK);
|
||||||
|
helper.assertTrue(knowledge.possessed(),
|
||||||
|
"A stack briefly held on the cursor must remain in possession history");
|
||||||
|
helper.assertTrue(knowledge.carried() == 0,
|
||||||
|
"Returning the stack must remove current stock without erasing its history");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void gazeStopsAtFirstBlockAndSpectatorsRevealNothing(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
Vec3 feet = helper.absoluteVec(new Vec3(1.5, 2, 1.5));
|
||||||
|
player.setPos(feet);
|
||||||
|
player.setYRot(0);
|
||||||
|
player.setXRot(0);
|
||||||
|
BlockPos eye = BlockPos.containing(player.getEyePosition());
|
||||||
|
// The observer deliberately never requests missing chunks. Load its short ray here,
|
||||||
|
// in the isolated test world, before asking it to observe anything.
|
||||||
|
int minX = (int) Math.floor(player.getX() - 1) >> 4;
|
||||||
|
int maxX = (int) Math.floor(player.getX() + 1) >> 4;
|
||||||
|
int minZ = (int) Math.floor(player.getZ() - 1) >> 4;
|
||||||
|
int maxZ = (int) Math.floor(player.getZ() + BlockKnowledgeService.OBSERVATION_DISTANCE + 1) >> 4;
|
||||||
|
for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) {
|
||||||
|
helper.getLevel().getChunk(x, z);
|
||||||
|
}
|
||||||
|
for (int z = 0; z <= 4; z++) {
|
||||||
|
helper.getLevel().setBlockAndUpdate(eye.offset(0, 0, z), Blocks.AIR.defaultBlockState());
|
||||||
|
}
|
||||||
|
BlockPos visible = eye.offset(0, 0, 2);
|
||||||
|
BlockPos hidden = eye.offset(0, 0, 3);
|
||||||
|
helper.getLevel().setBlockAndUpdate(visible, Blocks.STONE.defaultBlockState());
|
||||||
|
helper.getLevel().setBlockAndUpdate(hidden, Blocks.DIAMOND_ORE.defaultBlockState());
|
||||||
|
|
||||||
|
BlockKnowledgeService.observeLook(player);
|
||||||
|
helper.assertTrue(BlockKnowledgeService.knowledge(player, Blocks.STONE).seen(),
|
||||||
|
"The first block on the player's gaze ray must become seen");
|
||||||
|
helper.assertTrue(!BlockKnowledgeService.knowledge(player, Blocks.DIAMOND_ORE).known(),
|
||||||
|
"An ore behind the first block must remain unknown");
|
||||||
|
|
||||||
|
ServerPlayer spectator = (ServerPlayer) helper.makeMockServerPlayer(GameType.SPECTATOR);
|
||||||
|
spectator.setPos(feet);
|
||||||
|
spectator.setYRot(0);
|
||||||
|
spectator.setXRot(0);
|
||||||
|
helper.assertTrue(spectator.isSpectator(), "The observer fixture must be a spectator");
|
||||||
|
BlockKnowledgeService.observeLook(spectator);
|
||||||
|
helper.assertTrue(!BlockKnowledgeService.knowledge(spectator, Blocks.STONE).known()
|
||||||
|
&& !BlockKnowledgeService.knowledge(spectator, Blocks.DIAMOND_ORE).known(),
|
||||||
|
"Spectator gaze must not discover either visible or occluded blocks");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void existingStatsRemainPersonalWithoutInventingSightOrPlacements(GameTestHelper helper) {
|
||||||
|
ServerPlayer first = player(helper);
|
||||||
|
ServerPlayer second = player(helper);
|
||||||
|
// Restore vanilla values directly, without going through an action hook.
|
||||||
|
first.getStats().setValue(first, Stats.BLOCK_MINED.get(Blocks.DIAMOND_ORE), 7);
|
||||||
|
first.getStats().setValue(first, Stats.ITEM_DROPPED.get(Items.EMERALD_BLOCK), 4);
|
||||||
|
first.getStats().setValue(first, Stats.ITEM_PICKED_UP.get(Items.REDSTONE_BLOCK), 9);
|
||||||
|
var mined = BlockKnowledgeService.knowledge(first, Blocks.DIAMOND_ORE);
|
||||||
|
var dropped = BlockKnowledgeService.knowledge(first, Blocks.EMERALD_BLOCK);
|
||||||
|
var pickedUp = BlockKnowledgeService.knowledge(first, Blocks.REDSTONE_BLOCK);
|
||||||
|
helper.assertTrue(mined.mined() == 7 && !mined.possessed() && !mined.seen() && mined.placed() == 0,
|
||||||
|
"A historical mining counter must not invent possession, sight or placement evidence");
|
||||||
|
helper.assertTrue(dropped.dropped() == 4 && dropped.possessed() && !dropped.seen() && dropped.placed() == 0,
|
||||||
|
"Historical drops imply possession but cannot reconstruct sight or placements");
|
||||||
|
helper.assertTrue(pickedUp.possessed() && pickedUp.dropped() == 0 && !pickedUp.seen() && pickedUp.placed() == 0,
|
||||||
|
"Historical pickups imply possession without inventing other actions");
|
||||||
|
helper.assertTrue(mined.carried() == 0 && dropped.carried() == 0 && pickedUp.carried() == 0,
|
||||||
|
"Historical counters must not create current inventory stock");
|
||||||
|
|
||||||
|
BlockKnowledgeService.recordSeen(first, Blocks.GOLD_BLOCK);
|
||||||
|
helper.assertTrue(!BlockKnowledgeService.knowledge(second, Blocks.DIAMOND_ORE).known()
|
||||||
|
&& !BlockKnowledgeService.knowledge(second, Blocks.EMERALD_BLOCK).known()
|
||||||
|
&& !BlockKnowledgeService.knowledge(second, Blocks.REDSTONE_BLOCK).known()
|
||||||
|
&& !BlockKnowledgeService.knowledge(second, Blocks.GOLD_BLOCK).known(),
|
||||||
|
"Both vanilla evidence and Sanctuary history must stay isolated by player UUID");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void currentStockSumsPersonalSlotsAndLeavesPossessionHistory(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.LAPIS_BLOCK, 4));
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.LAPIS_BLOCK, 2));
|
||||||
|
player.containerMenu.setCarried(new ItemStack(Items.LAPIS_BLOCK, 3));
|
||||||
|
var held = BlockKnowledgeService.knowledge(player, Blocks.LAPIS_BLOCK);
|
||||||
|
helper.assertTrue(held.carried() == 9 && held.possessed(),
|
||||||
|
"Inventory, offhand and cursor must be counted once each");
|
||||||
|
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
|
||||||
|
player.containerMenu.setCarried(ItemStack.EMPTY);
|
||||||
|
var emptied = BlockKnowledgeService.knowledge(player, Blocks.LAPIS_BLOCK);
|
||||||
|
helper.assertTrue(emptied.carried() == 0 && emptied.possessed(),
|
||||||
|
"Empty current stock must retain historical possession");
|
||||||
|
helper.assertTrue(!emptied.seen() && emptied.mined() == 0 && emptied.placed() == 0 && emptied.dropped() == 0,
|
||||||
|
"Inventory evidence must not manufacture other kinds of progression");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void listenersReceiveUpdatedProgressionAndStockChanges(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
var uuid = player.getUUID();
|
||||||
|
List<Notice> notices = new ArrayList<>();
|
||||||
|
BlockKnowledgeEvents.CHANGED.register((source, knowledge, activity) -> {
|
||||||
|
if (source.getUUID().equals(uuid) && knowledge.blockId().equals("minecraft:gold_block")) {
|
||||||
|
notices.add(new Notice(activity, knowledge));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vanilla pickup inserts the stack before awarding its statistic. Inventory observation
|
||||||
|
// and the statistic hook must publish one discovery between them, then remain sticky.
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK));
|
||||||
|
player.awardStat(Stats.ITEM_PICKED_UP.get(Items.GOLD_BLOCK), 1);
|
||||||
|
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED).count() == 1,
|
||||||
|
"One pickup must not duplicate first-possession notifications through its inventory and stat paths");
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK, 2));
|
||||||
|
player.awardStat(Stats.ITEM_PICKED_UP.get(Items.GOLD_BLOCK), 1);
|
||||||
|
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED).count() == 1,
|
||||||
|
"A later pickup must change stock without publishing another first-possession notification");
|
||||||
|
|
||||||
|
BlockKnowledgeService.recordSeen(player, Blocks.GOLD_BLOCK);
|
||||||
|
BlockKnowledgeService.recordSeen(player, Blocks.GOLD_BLOCK);
|
||||||
|
player.awardStat(Stats.BLOCK_MINED.get(Blocks.GOLD_BLOCK), 2);
|
||||||
|
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
|
||||||
|
helper.getLevel().setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
|
||||||
|
helper.assertTrue(place(player, Items.GOLD_BLOCK, support, Direction.UP),
|
||||||
|
"The event fixture must place a gold block");
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.GOLD_BLOCK, 5));
|
||||||
|
BlockKnowledgeService.observeInventory(player);
|
||||||
|
player.getInventory().removeItem(player.getInventory().getSelectedSlot(), 1);
|
||||||
|
player.drop(new ItemStack(Items.GOLD_BLOCK), true, Prediction.PREDICTED);
|
||||||
|
|
||||||
|
helper.assertTrue(notices.stream().filter(n -> n.activity() == BlockKnowledgeEvents.Activity.SEEN).count() == 1,
|
||||||
|
"Repeated sight of the same block must not emit duplicate discoveries");
|
||||||
|
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.MINED && n.knowledge().mined() == 2),
|
||||||
|
"Mining listeners must receive the already updated vanilla counter");
|
||||||
|
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.PLACED && n.knowledge().placed() == 1),
|
||||||
|
"Placement listeners must receive the confirmed placement count");
|
||||||
|
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.POSSESSED && n.knowledge().possessed()),
|
||||||
|
"First possession must be available to future progression listeners");
|
||||||
|
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.DROPPED && n.knowledge().dropped() == 1),
|
||||||
|
"Drop listeners must receive the already updated vanilla counter");
|
||||||
|
helper.assertTrue(notices.stream().anyMatch(n -> n.activity() == BlockKnowledgeEvents.Activity.INVENTORY && n.knowledge().carried() == 4),
|
||||||
|
"Dropping an item must also notify consumers of the changed current stock");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void disconnectFromNetworkThreadSavesAndReloadsOnServer(GameTestHelper helper) throws InterruptedException {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
var server = helper.getLevel().getServer();
|
||||||
|
// A real packet listener, but no player-list registration or live connection. The only
|
||||||
|
// DISCONNECT subscriber in this test runtime is Sanctuary's history lifecycle handler.
|
||||||
|
var listener = new ServerGamePacketListenerImpl(server, new Connection(PacketFlow.SERVERBOUND),
|
||||||
|
player, CommonListenerCookie.createInitial(player.getGameProfile(), false));
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.PRISMARINE, 2));
|
||||||
|
BlockKnowledgeService.recordSeen(player, Blocks.PRISMARINE);
|
||||||
|
BlockKnowledgeService.recordPlaced(player, Blocks.PRISMARINE);
|
||||||
|
var directory = server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-blocodex");
|
||||||
|
var file = directory.resolve(player.getUUID() + ".json");
|
||||||
|
helper.assertTrue(!Files.exists(file), "Fresh history must still be in memory before its first save");
|
||||||
|
|
||||||
|
AtomicReference<Throwable> workerFailure = new AtomicReference<>();
|
||||||
|
Thread worker = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
if (server.isSameThread()) throw new AssertionError("The disconnect fixture must run outside the server thread");
|
||||||
|
ServerPlayConnectionEvents.DISCONNECT.invoker().onPlayDisconnect(listener, server);
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
workerFailure.set(failure);
|
||||||
|
}
|
||||||
|
}, "sanctuary-blocodex-disconnect-test");
|
||||||
|
worker.setDaemon(true);
|
||||||
|
worker.start();
|
||||||
|
worker.join(5000);
|
||||||
|
helper.assertTrue(!worker.isAlive(), "The network callback must return without waiting for the server tick");
|
||||||
|
helper.assertTrue(workerFailure.get() == null, "The network callback must safely schedule its work: " + workerFailure.get());
|
||||||
|
helper.assertTrue(!Files.exists(file),
|
||||||
|
"A network-thread disconnect must defer history writes until the server can execute them");
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
|
||||||
|
|
||||||
|
helper.runAfterDelay(2, () -> {
|
||||||
|
try {
|
||||||
|
helper.assertTrue(Files.isRegularFile(file), "The queued disconnect must save history on the server thread");
|
||||||
|
var disk = new BlockKnowledgeStore(directory, player.getUUID()).load().entry("minecraft:prismarine");
|
||||||
|
helper.assertTrue(disk.seen() && disk.possessed() && disk.placed() == 1,
|
||||||
|
"The saved file must retain all Sanctuary evidence from before disconnect");
|
||||||
|
var reloaded = BlockKnowledgeService.knowledge(player, Blocks.PRISMARINE);
|
||||||
|
helper.assertTrue(reloaded.seen() && reloaded.possessed() && reloaded.placed() == 1 && reloaded.carried() == 0,
|
||||||
|
"A fresh service session must reload history while rebuilding empty current stock");
|
||||||
|
BlockKnowledgeService.recordPlaced(player, Blocks.PRISMARINE);
|
||||||
|
BlockKnowledgeService.flush(server);
|
||||||
|
var updated = new BlockKnowledgeStore(directory, player.getUUID()).load().entry("minecraft:prismarine");
|
||||||
|
helper.assertTrue(updated.placed() == 2,
|
||||||
|
"A reloaded service session must be able to extend and flush its verified history");
|
||||||
|
helper.succeed();
|
||||||
|
} catch (IOException failure) {
|
||||||
|
helper.fail("Cannot read native block knowledge history: " + failure.getMessage());
|
||||||
|
} finally {
|
||||||
|
player.getTextFilter().leave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void structurePalettesCombinePersonalEvidenceWithAllowedMaterials(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = player(helper);
|
||||||
|
var natural = BlockKnowledgeService.NATURAL_STRUCTURE_MATERIALS;
|
||||||
|
helper.assertTrue(Blocks.STONE.defaultBlockState().is(natural)
|
||||||
|
&& Blocks.OAK_LOG.defaultBlockState().is(natural)
|
||||||
|
&& !Blocks.CONCRETE.pick(net.minecraft.world.item.DyeColor.WHITE).defaultBlockState().is(natural),
|
||||||
|
"The structure palette tag must include stone and logs while excluding concrete");
|
||||||
|
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.STONE, 3));
|
||||||
|
BlockKnowledgeService.observeInventory(player);
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
|
||||||
|
BlockKnowledgeService.recordSeen(player, Blocks.OAK_LOG);
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, new ItemStack(Items.CONCRETE.pick(net.minecraft.world.item.DyeColor.WHITE), 5));
|
||||||
|
|
||||||
|
var possessed = BlockKnowledgeService.palette(player, BlockKnowledgeFacet.POSSESSED, natural);
|
||||||
|
helper.assertTrue(possessed.stream().map(BlockKnowledge::blockId).toList().equals(List.of("minecraft:stone")),
|
||||||
|
"A possessed-material palette must retain historical stone, exclude seen-only oak and reject concrete");
|
||||||
|
helper.assertTrue(possessed.getFirst().carried() == 0,
|
||||||
|
"An unlocked material must not invent remaining stock after its items are removed");
|
||||||
|
helper.assertTrue(BlockKnowledgeService.palette(player, BlockKnowledgeFacet.CARRIED, natural).isEmpty(),
|
||||||
|
"Current natural stock must be empty even while the player carries disallowed concrete");
|
||||||
|
|
||||||
|
var discovered = BlockKnowledgeService.palette(player,
|
||||||
|
BlockKnowledgeFacet.SEEN.or(BlockKnowledgeFacet.POSSESSED), natural);
|
||||||
|
helper.assertTrue(discovered.stream().map(BlockKnowledge::blockId).toList()
|
||||||
|
.equals(List.of("minecraft:oak_log", "minecraft:stone")),
|
||||||
|
"Composed evidence must produce a stable sorted palette without admitting unknown materials");
|
||||||
|
helper.assertTrue(discovered.getFirst().seen() && !discovered.getFirst().possessed()
|
||||||
|
&& discovered.getFirst().carried() == 0,
|
||||||
|
"Seeing a log must not manufacture possession or a usable quantity");
|
||||||
|
boolean immutable = false;
|
||||||
|
try { discovered.clear(); }
|
||||||
|
catch (UnsupportedOperationException expected) { immutable = true; }
|
||||||
|
helper.assertTrue(immutable, "Consumers must receive an immutable material palette");
|
||||||
|
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.STONE, 2));
|
||||||
|
var available = BlockKnowledgeService.palette(player,
|
||||||
|
BlockKnowledgeFacet.POSSESSED.and(BlockKnowledgeFacet.CARRIED), natural);
|
||||||
|
helper.assertTrue(available.size() == 1 && available.getFirst().blockId().equals("minecraft:stone")
|
||||||
|
&& available.getFirst().carried() == 2,
|
||||||
|
"An available-material palette must report only the exact current allowed quantity");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Notice(BlockKnowledgeEvents.Activity activity, BlockKnowledge knowledge) { }
|
||||||
|
|
||||||
|
private static ServerPlayer player(GameTestHelper helper) {
|
||||||
|
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
|
||||||
|
player.setPos(helper.absoluteVec(new Vec3(5.5, 3, 5.5)));
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean place(ServerPlayer player, Item item, BlockPos support, Direction face) {
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||||
|
var hit = new BlockHitResult(Vec3.atCenterOf(support), face, support, false);
|
||||||
|
var context = new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit));
|
||||||
|
return ((BlockItem) item).place(context).consumesAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.census.LoadedChunks;
|
||||||
|
import fr.koka.sanctuary.knowledge.census.TerrainCensus;
|
||||||
|
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.world.item.DyeColor;
|
||||||
|
import net.minecraft.world.level.ChunkPos;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
|
||||||
|
/** Native material changes and the non-loading contract of the live terrain census. */
|
||||||
|
public final class CensusGameTestsTerrain {
|
||||||
|
@GameTest(maxTicks = 3000)
|
||||||
|
public void chunkCensusRecountsPlacementReplacementAndRemoval(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(2, 3, 2));
|
||||||
|
ChunkPos chunkPos = new ChunkPos(pos.getX() >> 4, pos.getZ() >> 4);
|
||||||
|
var original = level.getBlockState(pos);
|
||||||
|
long[] baseline = new long[2];
|
||||||
|
TerrainCensus.ChunkSnapshot[] immutableBefore = new TerrainCensus.ChunkSnapshot[1];
|
||||||
|
String pink = "minecraft:pink_glazed_terracotta";
|
||||||
|
String orange = "minecraft:orange_glazed_terracotta";
|
||||||
|
|
||||||
|
helper.startSequence()
|
||||||
|
.thenWaitUntil(() -> helper.assertTrue(current(helper, chunkPos).complete(),
|
||||||
|
"The fixture chunk must finish its bounded initial material census"))
|
||||||
|
.thenExecute(() -> {
|
||||||
|
var snapshot = current(helper, chunkPos);
|
||||||
|
baseline[0] = snapshot.blockCounts().getOrDefault(pink, 0L);
|
||||||
|
baseline[1] = snapshot.blockCounts().getOrDefault(orange, 0L);
|
||||||
|
immutableBefore[0] = snapshot;
|
||||||
|
level.setBlockAndUpdate(pos, Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK).defaultBlockState());
|
||||||
|
})
|
||||||
|
.thenWaitUntil(() -> {
|
||||||
|
var placed = current(helper, chunkPos);
|
||||||
|
helper.assertTrue(placed.complete() && placed.blockCounts().getOrDefault(pink, 0L) == baseline[0] + 1,
|
||||||
|
"A native placement must add exactly one material to its loaded chunk");
|
||||||
|
helper.assertTrue(immutableBefore[0].blockCounts().getOrDefault(pink, 0L) == baseline[0],
|
||||||
|
"A previously returned census must not mutate when terrain changes");
|
||||||
|
})
|
||||||
|
.thenExecute(() -> {
|
||||||
|
level.setBlockAndUpdate(pos, Blocks.GLAZED_TERRACOTTA.pick(DyeColor.ORANGE).defaultBlockState());
|
||||||
|
var pending = current(helper, chunkPos);
|
||||||
|
helper.assertTrue(pending.blockCounts().getOrDefault(pink, 0L) <= baseline[0],
|
||||||
|
"A dirty section must stop contributing its obsolete material count immediately");
|
||||||
|
helper.assertTrue(pending.countedSections() <= pending.loadedSections()
|
||||||
|
&& pending.coverage() >= 0 && pending.coverage() <= 1,
|
||||||
|
"Coverage must honestly describe the sections contributing to this snapshot");
|
||||||
|
})
|
||||||
|
.thenWaitUntil(() -> {
|
||||||
|
var replaced = current(helper, chunkPos);
|
||||||
|
helper.assertTrue(replaced.complete() && replaced.blockCounts().getOrDefault(pink, 0L) == baseline[0]
|
||||||
|
&& replaced.blockCounts().getOrDefault(orange, 0L) == baseline[1] + 1,
|
||||||
|
"Recounting must remove the replaced material and count its replacement once");
|
||||||
|
})
|
||||||
|
.thenExecute(() -> level.setBlockAndUpdate(pos, original))
|
||||||
|
.thenWaitUntil(() -> {
|
||||||
|
var removed = current(helper, chunkPos);
|
||||||
|
helper.assertTrue(removed.complete() && removed.blockCounts().getOrDefault(pink, 0L) == baseline[0]
|
||||||
|
&& removed.blockCounts().getOrDefault(orange, 0L) == baseline[1],
|
||||||
|
"Removing the placed material must restore its original material totals");
|
||||||
|
helper.assertTrue(removed.dimensionId().equals(level.dimension().identifier().toString())
|
||||||
|
&& removed.chunkX() == chunkPos.x() && removed.chunkZ() == chunkPos.z(),
|
||||||
|
"Spatial snapshots must retain their stable dimension and chunk coordinates");
|
||||||
|
}).thenSucceed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void missingChunksStayUnloadedAndRepeatedReadsShareBudget(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
var server = level.getServer();
|
||||||
|
ChunkPos absent = new ChunkPos(1_000_000, 1_000_000);
|
||||||
|
helper.assertTrue(level.getChunkSource().getChunkNow(absent.x(), absent.z()) == null,
|
||||||
|
"The distant fixture must begin unloaded");
|
||||||
|
int holdersBefore = level.getChunkSource().getLoadedChunksCount();
|
||||||
|
var first = TerrainCensus.snapshot(server);
|
||||||
|
int countedBefore = first.dimensions().stream().mapToInt(TerrainCensus.DimensionSnapshot::countedSections).sum();
|
||||||
|
for (int read = 0; read < 32; read++) {
|
||||||
|
helper.assertTrue(TerrainCensus.chunkSnapshot(level, absent).isEmpty(),
|
||||||
|
"Requesting spatial metadata for a missing chunk must return absence");
|
||||||
|
}
|
||||||
|
var repeated = TerrainCensus.snapshot(server);
|
||||||
|
helper.assertTrue(repeated.dimensions().stream().mapToInt(TerrainCensus.DimensionSnapshot::countedSections).sum() == countedBefore,
|
||||||
|
"Repeated reads in the same tick must not spend another palette scan budget");
|
||||||
|
helper.assertTrue(level.getChunkSource().getLoadedChunksCount() == holdersBefore
|
||||||
|
&& level.getChunkSource().getChunkNow(absent.x(), absent.z()) == null,
|
||||||
|
"Census reads must neither create holders nor load or generate distant chunks");
|
||||||
|
var dimension = repeated.dimensions().stream()
|
||||||
|
.filter(value -> value.dimensionId().equals(level.dimension().identifier().toString())).findFirst().orElseThrow();
|
||||||
|
helper.assertTrue(dimension.loadedChunks() == LoadedChunks.full(level).size(),
|
||||||
|
"Coverage must count only currently available FULL chunks, excluding unloaded terrain");
|
||||||
|
helper.assertTrue(!dimension.blockCounts().containsKey("minecraft:air")
|
||||||
|
&& !dimension.blockCounts().containsKey("minecraft:cave_air")
|
||||||
|
&& !dimension.blockCounts().containsKey("minecraft:void_air"),
|
||||||
|
"Terrain materials must not count empty space as available matter");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainCensus.ChunkSnapshot current(GameTestHelper helper, ChunkPos pos) {
|
||||||
|
var result = TerrainCensus.chunkSnapshot(helper.getLevel(), pos);
|
||||||
|
helper.assertTrue(result.isPresent(), "The active GameTest fixture chunk must already be loaded FULL");
|
||||||
|
return result.orElseThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.census.StockCensus;
|
||||||
|
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.CompoundContainer;
|
||||||
|
import net.minecraft.world.entity.EntityTypes;
|
||||||
|
import net.minecraft.world.entity.EquipmentSlot;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.entity.vehicle.minecart.MinecartChest;
|
||||||
|
import net.minecraft.world.inventory.ChestMenu;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.GameType;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.level.block.ChestBlock;
|
||||||
|
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||||
|
import net.minecraft.world.level.block.entity.HopperBlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.properties.ChestType;
|
||||||
|
import net.minecraft.world.level.storage.loot.BuiltInLootTables;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/** Native fixtures: a census must not move items, invent stock or open unopened loot. */
|
||||||
|
public final class CensusStockGameTests {
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void categoriesRemainSeparateAndOpenDoubleChestIsCountedOnce(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
var server = level.getServer();
|
||||||
|
var before = StockCensus.snapshot(server);
|
||||||
|
BlockPos leftPos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
BlockPos rightPos = leftPos.east();
|
||||||
|
BlockPos hopperPos = helper.absolutePos(new BlockPos(4, 1, 1));
|
||||||
|
var state = Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING, Direction.NORTH);
|
||||||
|
level.setBlockAndUpdate(leftPos, state.setValue(ChestBlock.TYPE, ChestType.LEFT));
|
||||||
|
level.setBlockAndUpdate(rightPos, state.setValue(ChestBlock.TYPE, ChestType.RIGHT));
|
||||||
|
level.setBlockAndUpdate(hopperPos, Blocks.HOPPER.defaultBlockState());
|
||||||
|
var left = (ChestBlockEntity) level.getBlockEntity(leftPos);
|
||||||
|
var right = (ChestBlockEntity) level.getBlockEntity(rightPos);
|
||||||
|
var hopper = (HopperBlockEntity) level.getBlockEntity(hopperPos);
|
||||||
|
left.setItem(0, new ItemStack(Items.GOLD_INGOT, 3));
|
||||||
|
right.setItem(0, new ItemStack(Items.GOLD_INGOT, 5));
|
||||||
|
hopper.setItem(0, new ItemStack(Items.GOLD_INGOT, 7));
|
||||||
|
|
||||||
|
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
|
||||||
|
player.getInventory().setItem(0, new ItemStack(Items.GOLD_INGOT, 6));
|
||||||
|
player.setItemSlot(EquipmentSlot.OFFHAND, new ItemStack(Items.GOLD_INGOT, 2));
|
||||||
|
player.setItemSlot(EquipmentSlot.HEAD, new ItemStack(Items.CARVED_PUMPKIN));
|
||||||
|
player.containerMenu = ChestMenu.sixRows(1, player.getInventory(), new CompoundContainer(left, right));
|
||||||
|
player.containerMenu.setCarried(new ItemStack(Items.GOLD_INGOT, 3));
|
||||||
|
helper.assertTrue(!server.getPlayerList().getPlayers().contains(player), "Mock player must start outside the online list");
|
||||||
|
helper.assertTrue(StockCensus.snapshot(server).playerItems().equals(before.playerItems()),
|
||||||
|
"An offline player's inventory must not enter loaded online stock");
|
||||||
|
|
||||||
|
Vec3 position = helper.absoluteVec(new Vec3(3.5, 2, 3.5));
|
||||||
|
var cart = new MinecartChest(EntityTypes.CHEST_MINECART, level);
|
||||||
|
cart.setPos(position);
|
||||||
|
cart.setItem(0, new ItemStack(Items.GOLD_INGOT, 11));
|
||||||
|
level.addFreshEntity(cart);
|
||||||
|
var item = new ItemEntity(level, position.x, position.y, position.z, new ItemStack(Items.GOLD_INGOT, 13));
|
||||||
|
level.addFreshEntity(item);
|
||||||
|
server.getPlayerList().getPlayers().add(player);
|
||||||
|
try {
|
||||||
|
var census = StockCensus.snapshot(server);
|
||||||
|
String gold = "minecraft:gold_ingot";
|
||||||
|
helper.assertTrue(census.playerItems().getOrDefault(gold, 0L) - before.playerItems().getOrDefault(gold, 0L) == 11,
|
||||||
|
"Personal stock must include main inventory, equipment and cursor without the open chest");
|
||||||
|
helper.assertTrue(census.blockContainerItems().getOrDefault(gold, 0L) - before.blockContainerItems().getOrDefault(gold, 0L) == 15,
|
||||||
|
"Two chest halves and a hopper must each be counted once");
|
||||||
|
helper.assertTrue(census.entityContainerItems().getOrDefault(gold, 0L) - before.entityContainerItems().getOrDefault(gold, 0L) == 11,
|
||||||
|
"Chest minecart stock belongs to its own category");
|
||||||
|
helper.assertTrue(census.groundItems().getOrDefault(gold, 0L) - before.groundItems().getOrDefault(gold, 0L) == 13,
|
||||||
|
"Dropped stack units belong only to ground stock");
|
||||||
|
helper.assertTrue(census.total(gold) - before.total(gold) == 50, "All four categories must total 50 real items");
|
||||||
|
helper.assertTrue(census.playerItems().getOrDefault("minecraft:carved_pumpkin", 0L)
|
||||||
|
- before.playerItems().getOrDefault("minecraft:carved_pumpkin", 0L) == 1,
|
||||||
|
"Head equipment must be included exactly once");
|
||||||
|
helper.assertTrue(left.getItem(0).getCount() == 3 && right.getItem(0).getCount() == 5 && hopper.getItem(0).getCount() == 7,
|
||||||
|
"A stock census must preserve all container contents");
|
||||||
|
helper.assertTrue(census.coverage().partial() && !census.coverage().offlineIncluded()
|
||||||
|
&& !census.coverage().nestedContentsIncluded() && !census.coverage().mobInventoriesIncluded(),
|
||||||
|
"Coverage must explicitly distinguish this loaded census from total world wealth");
|
||||||
|
boolean immutable = false;
|
||||||
|
try { census.playerItems().clear(); } catch (UnsupportedOperationException expected) { immutable = true; }
|
||||||
|
helper.assertTrue(immutable, "Consumers must receive immutable stock maps");
|
||||||
|
} finally {
|
||||||
|
server.getPlayerList().getPlayers().remove(player);
|
||||||
|
cart.discard();
|
||||||
|
item.discard();
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void unopenedBlockAndVehicleLootRemainUnresolved(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
var server = level.getServer();
|
||||||
|
var before = StockCensus.snapshot(server);
|
||||||
|
BlockPos chestPos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
level.setBlockAndUpdate(chestPos, Blocks.CHEST.defaultBlockState());
|
||||||
|
var chest = (ChestBlockEntity) level.getBlockEntity(chestPos);
|
||||||
|
chest.setLootTable(BuiltInLootTables.SIMPLE_DUNGEON, 41L);
|
||||||
|
|
||||||
|
var cart = new MinecartChest(EntityTypes.CHEST_MINECART, level);
|
||||||
|
cart.setPos(helper.absoluteVec(new Vec3(3.5, 2, 3.5)));
|
||||||
|
cart.setContainerLootTable(BuiltInLootTables.ABANDONED_MINESHAFT);
|
||||||
|
cart.setContainerLootTableSeed(73L);
|
||||||
|
level.addFreshEntity(cart);
|
||||||
|
try {
|
||||||
|
var census = StockCensus.snapshot(server);
|
||||||
|
helper.assertTrue(chest.getLootTable().equals(BuiltInLootTables.SIMPLE_DUNGEON) && chest.getLootTableSeed() == 41L,
|
||||||
|
"Reading stock must preserve the chest's unopened loot table and seed");
|
||||||
|
helper.assertTrue(cart.getContainerLootTable().equals(BuiltInLootTables.ABANDONED_MINESHAFT)
|
||||||
|
&& cart.getContainerLootTableSeed() == 73L,
|
||||||
|
"Reading stock must also preserve a minecart's unopened loot table and seed");
|
||||||
|
helper.assertTrue(census.coverage().pendingLootContainers() - before.coverage().pendingLootContainers() == 2,
|
||||||
|
"Both unopened containers must be explicitly reported as omitted loot");
|
||||||
|
helper.assertTrue(census.blockContainerItems().equals(before.blockContainerItems())
|
||||||
|
&& census.entityContainerItems().equals(before.entityContainerItems()),
|
||||||
|
"An unresolved loot table must not manufacture counted item stacks");
|
||||||
|
} finally {
|
||||||
|
// Prevent cleanup from generating loot after the assertions.
|
||||||
|
cart.setContainerLootTable(null);
|
||||||
|
cart.discard();
|
||||||
|
chest.setLootTable(null);
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package fr.koka.sanctuary.gametest;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import fr.koka.sanctuary.knowledge.census.WorldCensusService;
|
||||||
|
import fr.koka.sanctuary.knowledge.history.MaterialActivity;
|
||||||
|
import fr.koka.sanctuary.knowledge.history.MaterialActivityHistory;
|
||||||
|
import fr.koka.sanctuary.knowledge.history.MaterialActivityStore;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.fabricmc.fabric.api.gametest.v1.GameTest;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.commands.CommandSource;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.stats.Stats;
|
||||||
|
import net.minecraft.util.Prediction;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.item.context.UseOnContext;
|
||||||
|
import net.minecraft.world.level.GameType;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/** Activity uses native action callbacks and stays separate from current stock and discovery. */
|
||||||
|
public final class MaterialActivityGameTests {
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void nativeActionsProduceDatedDeltasWhileObservationsProduceNone(GameTestHelper helper) throws IOException {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
var server = level.getServer();
|
||||||
|
LocalDate day = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
var before = MaterialActivityHistory.snapshot(server, day);
|
||||||
|
var originalCounts = Map.copyOf(before.counts());
|
||||||
|
ServerPlayer player = (ServerPlayer) helper.makeMockServerPlayer(GameType.SURVIVAL);
|
||||||
|
player.setPos(helper.absoluteVec(new Vec3(5.5, 3, 5.5)));
|
||||||
|
|
||||||
|
// Giving inventory, restoring old vanilla totals and reading observations are not flows.
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.OAK_LOG, 5));
|
||||||
|
player.getStats().setValue(player, Stats.BLOCK_MINED.get(Blocks.GOLD_BLOCK), 99);
|
||||||
|
player.getStats().setValue(player, Stats.ITEM_DROPPED.get(Items.GOLD_INGOT), 999);
|
||||||
|
BlockKnowledgeService.observeInventory(player);
|
||||||
|
BlockKnowledgeService.snapshot(player);
|
||||||
|
WorldCensusService.capture(server);
|
||||||
|
helper.assertTrue(MaterialActivityHistory.snapshot(server, day).counts().equals(before.counts()),
|
||||||
|
"Inventory grants, historical totals and census/discovery reads must not invent dated activity");
|
||||||
|
|
||||||
|
BlockPos minedPos = helper.absolutePos(new BlockPos(3, 1, 1));
|
||||||
|
level.setBlockAndUpdate(minedPos, Blocks.STONE.defaultBlockState());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIAMOND_PICKAXE));
|
||||||
|
helper.assertTrue(player.gameMode.destroyBlock(minedPos), "The player must harvest stone with a suitable tool");
|
||||||
|
level.setBlockAndUpdate(minedPos, Blocks.STONE.defaultBlockState());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY);
|
||||||
|
player.gameMode.destroyBlock(minedPos);
|
||||||
|
|
||||||
|
BlockPos support = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
level.setBlockAndUpdate(support, Blocks.STONE.defaultBlockState());
|
||||||
|
level.setBlockAndUpdate(support.above(), Blocks.AIR.defaultBlockState());
|
||||||
|
helper.assertTrue(place(player, Items.COBBLESTONE, support), "The native cobblestone placement must succeed");
|
||||||
|
BlockPos badSupport = helper.absolutePos(new BlockPos(1, 1, 3));
|
||||||
|
level.setBlockAndUpdate(badSupport, Blocks.STONE.defaultBlockState());
|
||||||
|
level.setBlockAndUpdate(badSupport.above(), Blocks.AIR.defaultBlockState());
|
||||||
|
helper.assertTrue(!place(player, Items.OAK_SAPLING, badSupport), "Stone must refuse the native sapling placement");
|
||||||
|
|
||||||
|
// Non-block items exercise the general material history rather than the block-only codex.
|
||||||
|
var item = new ItemEntity(level, player.getX(), player.getY(), player.getZ(), new ItemStack(Items.DIAMOND, 4));
|
||||||
|
item.setNoPickUpDelay();
|
||||||
|
level.addFreshEntity(item);
|
||||||
|
item.playerTouch(player);
|
||||||
|
helper.assertTrue(item.isRemoved(), "The native item pickup must consume the complete ground stack");
|
||||||
|
int diamondSlot = player.getInventory().findSlotMatchingItem(new ItemStack(Items.DIAMOND));
|
||||||
|
helper.assertTrue(diamondSlot >= 0, "The picked-up diamonds must now occupy a player inventory slot");
|
||||||
|
ItemStack dropped = player.getInventory().removeItem(diamondSlot, 3);
|
||||||
|
helper.assertTrue(dropped.getCount() == 3, "The native drop fixture must remove exactly three held items");
|
||||||
|
player.drop(dropped, true, Prediction.PREDICTED);
|
||||||
|
player.getInventory().dropAll();
|
||||||
|
|
||||||
|
// This is vanilla's successful craft-completion callback, not a simulated recipe match.
|
||||||
|
new ItemStack(Items.BREAD, 3).onCraftedBy(player, 3);
|
||||||
|
var after = MaterialActivityHistory.snapshot(server, day);
|
||||||
|
helper.assertTrue(delta(before, after, "mined", "block", "minecraft:stone") == 1,
|
||||||
|
"Only the successful vanilla harvest may add a mined-block activity");
|
||||||
|
helper.assertTrue(delta(before, after, "placed", "block", "minecraft:cobblestone") == 1
|
||||||
|
&& delta(before, after, "placed", "block", "minecraft:oak_sapling") == 0,
|
||||||
|
"Only confirmed placement may contribute to the dated placement count");
|
||||||
|
helper.assertTrue(delta(before, after, "picked_up", "item", "minecraft:diamond") == 4,
|
||||||
|
"A native pickup must record non-block item units exactly once");
|
||||||
|
helper.assertTrue(delta(before, after, "dropped", "item", "minecraft:diamond") == 3,
|
||||||
|
"Voluntary drop units must be recorded without including the death-drop path");
|
||||||
|
helper.assertTrue(delta(before, after, "crafted", "item", "minecraft:bread") == 3,
|
||||||
|
"Vanilla's craft-completion callback must record its reported output quantity");
|
||||||
|
helper.assertTrue(after.recorded() && after.unsaved(), "New activity must be identified as recorded and awaiting save");
|
||||||
|
|
||||||
|
WorldCensusService.capture(server);
|
||||||
|
BlockKnowledgeService.snapshot(player);
|
||||||
|
helper.assertTrue(MaterialActivityHistory.snapshot(server, day).counts().equals(after.counts()),
|
||||||
|
"Subsequent stock and knowledge reads must not duplicate historical activity");
|
||||||
|
boolean immutable = false;
|
||||||
|
try { after.counts().clear(); }
|
||||||
|
catch (UnsupportedOperationException expected) { immutable = true; }
|
||||||
|
helper.assertTrue(immutable, "Activity consumers must receive immutable day snapshots");
|
||||||
|
helper.assertTrue(before.counts().equals(originalCounts),
|
||||||
|
"The earlier immutable snapshot must retain its original totals");
|
||||||
|
|
||||||
|
MaterialActivityHistory.flush(server);
|
||||||
|
var stored = new MaterialActivityStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-activity"), day).load();
|
||||||
|
helper.assertTrue(stored.equals(after.counts()), "A fresh store must read the exact flushed native activity totals");
|
||||||
|
helper.assertTrue(!MaterialActivityHistory.snapshot(server, day).unsaved(), "Successful flush must clear the unsaved status");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void unrecordedDaysRemainUnknownRatherThanZeroActivity(GameTestHelper helper) throws IOException {
|
||||||
|
LocalDate missingDay = LocalDate.now(ZoneOffset.UTC).minusYears(1000);
|
||||||
|
var snapshot = MaterialActivityHistory.snapshot(helper.getLevel().getServer(), missingDay);
|
||||||
|
helper.assertTrue(!snapshot.recorded() && !snapshot.unsaved() && snapshot.counts().isEmpty(),
|
||||||
|
"An absent day must be explicitly unrecorded, without manufacturing a zero-activity record");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(maxTicks = 100)
|
||||||
|
public void registeredCensusAndHistoryCommandsAcceptNamespacedIdsAndDates(GameTestHelper helper) throws CommandSyntaxException {
|
||||||
|
var server = helper.getLevel().getServer();
|
||||||
|
var source = server.createCommandSourceStack().withLevel(helper.getLevel())
|
||||||
|
.withSource(CommandSource.NULL).withSuppressedOutput();
|
||||||
|
var dispatcher = server.getCommands().getDispatcher();
|
||||||
|
String today = LocalDate.now(ZoneOffset.UTC).toString();
|
||||||
|
String missing = LocalDate.now(ZoneOffset.UTC).minusYears(1000).toString();
|
||||||
|
for (String command : List.of("sanctuary census", "sanctuary census block minecraft:stone",
|
||||||
|
"sanctuary census item minecraft:diamond", "sanctuary history",
|
||||||
|
"sanctuary history " + today, "sanctuary history " + missing)) {
|
||||||
|
var parsed = dispatcher.parse(command, source);
|
||||||
|
helper.assertTrue(!parsed.getReader().canRead(),
|
||||||
|
"The registered dispatcher must consume the complete namespaced identifier or date: " + command);
|
||||||
|
helper.assertTrue(dispatcher.execute(parsed) == 1,
|
||||||
|
"The registered read-only census/history command must execute successfully: " + command);
|
||||||
|
}
|
||||||
|
helper.assertTrue(dispatcher.execute("sanctuary census block minecraft:missing_census_material", source) == 0,
|
||||||
|
"An unknown material must fail explicitly instead of claiming a valid zero count");
|
||||||
|
helper.assertTrue(dispatcher.execute("sanctuary history not-a-date", source) == 0,
|
||||||
|
"An invalid history date must fail with the native command's validation result");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long delta(MaterialActivityHistory.Snapshot before, MaterialActivityHistory.Snapshot after,
|
||||||
|
String action, String registry, String id) {
|
||||||
|
var key = new MaterialActivity(action, registry, id);
|
||||||
|
return after.counts().getOrDefault(key, 0L) - before.counts().getOrDefault(key, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean place(ServerPlayer player, Item item, BlockPos support) {
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(item));
|
||||||
|
var hit = new BlockHitResult(Vec3.atCenterOf(support), Direction.UP, support, false);
|
||||||
|
return ((BlockItem) item).place(new BlockPlaceContext(new UseOnContext(player, InteractionHand.MAIN_HAND, hit))).consumesAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"environment": "*",
|
"environment": "*",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"fabric-gametest": ["${gametest_entrypoint}"],
|
"fabric-gametest": ["${gametest_entrypoint}", "fr.koka.sanctuary.gametest.BlockKnowledgeGameTests", "fr.koka.sanctuary.gametest.CensusGameTestsTerrain", "fr.koka.sanctuary.gametest.CensusStockGameTests", "fr.koka.sanctuary.gametest.MaterialActivityGameTests"],
|
||||||
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
|
"fabric-client-gametest": ["fr.koka.sanctuary.gametest.SanctuaryClientRenderTests"]
|
||||||
},
|
},
|
||||||
"mixins": ["sanctuary-gametest.mixins.json"],
|
"mixins": ["sanctuary-gametest.mixins.json"],
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public final class SanctuaryMod implements ModInitializer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onInitialize() {
|
public void onInitialize() {
|
||||||
|
fr.koka.sanctuary.knowledge.census.WorldCensusService.register();
|
||||||
|
fr.koka.sanctuary.knowledge.history.MaterialActivityHistory.register();
|
||||||
|
fr.koka.sanctuary.knowledge.BlockKnowledgeService.register();
|
||||||
|
fr.koka.sanctuary.knowledge.BlockKnowledgeNetworking.register();
|
||||||
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
|
Registry.register(BuiltInRegistries.CHUNK_GENERATOR, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionChunkGenerator.CODEC);
|
||||||
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
|
Registry.register(BuiltInRegistries.BIOME_SOURCE, id("expansion_lab"), fr.koka.sanctuary.expansion.ExpansionBiomeSource.CODEC);
|
||||||
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
|
Registry.register(BuiltInRegistries.DENSITY_FUNCTION_TYPE, id("expansion_island"), fr.koka.sanctuary.expansion.ExpansionDensity.CODEC);
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package fr.koka.sanctuary.client;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledge;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.CycleButton;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.MultiLineTextWidget;
|
||||||
|
import net.minecraft.client.gui.components.ObjectSelectionList;
|
||||||
|
import net.minecraft.client.gui.components.ScrollableLayout;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
|
import net.minecraft.client.gui.layouts.LinearLayout;
|
||||||
|
import net.minecraft.client.gui.screens.Screen;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.network.chat.CommonComponents;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
|
||||||
|
/** A personal server snapshot, with independent history facets and current carried stock. */
|
||||||
|
public final class SanctuaryBlocodexScreen extends Screen {
|
||||||
|
private static final AtomicInteger REQUEST_IDS = new AtomicInteger();
|
||||||
|
private static final int ROW_HEIGHT = 44;
|
||||||
|
private static final int LIST_TOP = 98;
|
||||||
|
private static final int TEXT = 0xFFE6E6E6;
|
||||||
|
private static final int MUTED = 0xFFAAAAAA;
|
||||||
|
private List<Row> catalogue = List.of();
|
||||||
|
private List<Row> filtered = List.of();
|
||||||
|
private final List<BlockKnowledge> incoming = new ArrayList<>();
|
||||||
|
private final Set<String> incomingIds = new HashSet<>();
|
||||||
|
private EntryList list;
|
||||||
|
private EditBox search;
|
||||||
|
private Button previous;
|
||||||
|
private Button next;
|
||||||
|
private Button refresh;
|
||||||
|
private Filter filter = Filter.ALL;
|
||||||
|
private String query = "";
|
||||||
|
private int page;
|
||||||
|
private int pageSize = 1;
|
||||||
|
private int activeRequest;
|
||||||
|
private int nextPart;
|
||||||
|
private int expectedParts;
|
||||||
|
private int expectedTotal;
|
||||||
|
private int ticks;
|
||||||
|
private int lastRequestTick = -120;
|
||||||
|
private boolean pending;
|
||||||
|
private boolean loaded;
|
||||||
|
private boolean available = true;
|
||||||
|
|
||||||
|
public SanctuaryBlocodexScreen() {
|
||||||
|
super(label("title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void init() {
|
||||||
|
int margin = 12;
|
||||||
|
search = addRenderableWidget(new EditBox(font, margin, 44, Math.max(80, width - 126), 20, label("search")));
|
||||||
|
search.setMaxLength(128);
|
||||||
|
search.setHint(label("search"));
|
||||||
|
search.setValue(query);
|
||||||
|
search.setResponder(value -> {
|
||||||
|
query = value;
|
||||||
|
page = 0;
|
||||||
|
filterRows();
|
||||||
|
});
|
||||||
|
refresh = addRenderableWidget(Button.builder(label("refresh"), button -> request())
|
||||||
|
.bounds(width - 106, 44, 94, 20).build());
|
||||||
|
addRenderableWidget(CycleButton.builder(Filter::label, filter).withValues(Filter.values())
|
||||||
|
.create(margin, 70, Math.max(140, width - 126), 20, label("filter"), (button, value) -> {
|
||||||
|
filter = value;
|
||||||
|
page = 0;
|
||||||
|
filterRows();
|
||||||
|
}));
|
||||||
|
addRenderableWidget(Button.builder(label("help"), button -> minecraft.gui.setScreen(new HelpScreen(this)))
|
||||||
|
.bounds(width - 106, 70, 94, 20).tooltip(Tooltip.create(label("help_text"))).build());
|
||||||
|
|
||||||
|
int listHeight = Math.max(ROW_HEIGHT + 8, height - LIST_TOP - 42);
|
||||||
|
pageSize = Math.max(1, (listHeight - 8) / ROW_HEIGHT);
|
||||||
|
list = addRenderableWidget(new EntryList(width - 24, listHeight, LIST_TOP));
|
||||||
|
list.setX(margin);
|
||||||
|
previous = addRenderableWidget(Button.builder(Component.literal("<"), button -> turnPage(-1))
|
||||||
|
.bounds(margin, height - 30, 26, 20).tooltip(Tooltip.create(label("previous"))).build());
|
||||||
|
next = addRenderableWidget(Button.builder(Component.literal(">"), button -> turnPage(1))
|
||||||
|
.bounds(margin + 32, height - 30, 26, 20).tooltip(Tooltip.create(label("next"))).build());
|
||||||
|
addRenderableWidget(Button.builder(CommonComponents.GUI_DONE, button -> onClose())
|
||||||
|
.bounds(width - 92, height - 30, 80, 20).build());
|
||||||
|
filterRows();
|
||||||
|
setInitialFocus(search);
|
||||||
|
if (!loaded && !pending) request();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tick() {
|
||||||
|
ticks++;
|
||||||
|
if (pending && ticks - lastRequestTick >= 120) fail();
|
||||||
|
if (!pending && ticks - lastRequestTick >= 40) request();
|
||||||
|
refresh.active = !pending && ticks - lastRequestTick >= 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void request() {
|
||||||
|
if (pending || ticks - lastRequestTick < 22) return;
|
||||||
|
lastRequestTick = ticks;
|
||||||
|
if (minecraft.player == null || minecraft.getConnection() == null
|
||||||
|
|| !ClientPlayNetworking.canSend(BlockKnowledgePayloads.Request.TYPE)) {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeRequest = REQUEST_IDS.incrementAndGet();
|
||||||
|
nextPart = 0;
|
||||||
|
expectedParts = -1;
|
||||||
|
expectedTotal = -1;
|
||||||
|
incoming.clear();
|
||||||
|
incomingIds.clear();
|
||||||
|
pending = true;
|
||||||
|
refresh.active = false;
|
||||||
|
ClientPlayNetworking.send(new BlockKnowledgePayloads.Request(activeRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called on the client thread; nothing becomes visible until every fragment is validated. */
|
||||||
|
public void accept(BlockKnowledgePayloads.SnapshotPart part) {
|
||||||
|
if (!pending || part.requestId() != activeRequest) return;
|
||||||
|
if (!part.healthy() || part.partIndex() != nextPart
|
||||||
|
|| (expectedParts != -1 && (part.partCount() != expectedParts || part.totalEntries() != expectedTotal))) {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expectedParts = part.partCount();
|
||||||
|
expectedTotal = part.totalEntries();
|
||||||
|
for (BlockKnowledge knowledge : part.entries()) {
|
||||||
|
if (!incomingIds.add(knowledge.blockId())) {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
incoming.add(knowledge);
|
||||||
|
}
|
||||||
|
nextPart++;
|
||||||
|
if (nextPart != expectedParts) return;
|
||||||
|
if (incoming.size() != expectedTotal) {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catalogue = incoming.stream().map(Row::new)
|
||||||
|
.sorted(Comparator.comparing(Row::name, String.CASE_INSENSITIVE_ORDER).thenComparing(row -> row.knowledge().blockId()))
|
||||||
|
.toList();
|
||||||
|
incoming.clear();
|
||||||
|
incomingIds.clear();
|
||||||
|
loaded = true;
|
||||||
|
available = true;
|
||||||
|
pending = false;
|
||||||
|
filterRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fail() {
|
||||||
|
pending = false;
|
||||||
|
available = false;
|
||||||
|
loaded = false;
|
||||||
|
catalogue = List.of();
|
||||||
|
incoming.clear();
|
||||||
|
incomingIds.clear();
|
||||||
|
filterRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void filterRows() {
|
||||||
|
String normalized = query.strip().toLowerCase(Locale.ROOT);
|
||||||
|
filtered = catalogue.stream().filter(row -> filter.matches(row.knowledge()))
|
||||||
|
.filter(row -> normalized.isEmpty() || row.name().toLowerCase(Locale.ROOT).contains(normalized)
|
||||||
|
|| row.knowledge().blockId().toLowerCase(Locale.ROOT).contains(normalized)).toList();
|
||||||
|
showPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPage() {
|
||||||
|
if (list == null) return;
|
||||||
|
page = Math.max(0, Math.min(page, pageCount() - 1));
|
||||||
|
int from = page * pageSize;
|
||||||
|
String selected = list.getSelected() == null ? null : list.getSelected().row.knowledge().blockId();
|
||||||
|
List<Entry> entries = filtered.subList(Math.min(from, filtered.size()), Math.min(from + pageSize, filtered.size()))
|
||||||
|
.stream().map(Entry::new).toList();
|
||||||
|
list.replaceEntries(entries);
|
||||||
|
list.setSelected(entries.stream().filter(entry -> entry.row.knowledge().blockId().equals(selected)).findFirst().orElse(null));
|
||||||
|
list.setScrollAmount(0);
|
||||||
|
previous.active = page > 0;
|
||||||
|
next.active = page + 1 < pageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pageCount() { return Math.max(1, (filtered.size() + pageSize - 1) / pageSize); }
|
||||||
|
|
||||||
|
private void turnPage(int delta) {
|
||||||
|
page += delta;
|
||||||
|
showPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean mouseScrolled(double mouseX, double mouseY, double horizontal, double vertical) {
|
||||||
|
if (list.isMouseOver(mouseX, mouseY) && vertical != 0) {
|
||||||
|
turnPage(vertical < 0 ? 1 : -1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.mouseScrolled(mouseX, mouseY, horizontal, vertical);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||||
|
graphics.centeredText(font, title, width / 2, 12, TEXT);
|
||||||
|
Component summary = !available ? label("unavailable") : !loaded ? label("loading")
|
||||||
|
: label("summary", catalogue.stream().filter(row -> row.knowledge().known()).count(), catalogue.size(), filtered.size());
|
||||||
|
graphics.centeredText(font, font.plainSubstrByWidth(summary.getString(), width - 20), width / 2, 28, MUTED);
|
||||||
|
graphics.centeredText(font, label("page", page + 1, pageCount()), width / 2, height - 24, MUTED);
|
||||||
|
if (loaded && filtered.isEmpty()) graphics.centeredText(font, label("empty"), width / 2, LIST_TOP + 18, MUTED);
|
||||||
|
if (!available) graphics.centeredText(font, label("unavailable_hint"), width / 2, LIST_TOP + 18, MUTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isPauseScreen() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removed() {
|
||||||
|
pending = false;
|
||||||
|
incoming.clear();
|
||||||
|
incomingIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component label(String key, Object... arguments) {
|
||||||
|
return Component.translatable("sanctuary.blocodex." + key, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component yesNo(boolean value) { return label(value ? "yes" : "no"); }
|
||||||
|
|
||||||
|
private static String compact(long value) {
|
||||||
|
if (value < 10_000) return Long.toString(value);
|
||||||
|
if (value < 1_000_000) return (value / 1_000) + "k";
|
||||||
|
if (value < 1_000_000_000) return (value / 1_000_000) + "M";
|
||||||
|
if (value < 1_000_000_000_000L) return (value / 1_000_000_000) + "G";
|
||||||
|
return (value / 1_000_000_000_000L) + "T";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Row(BlockKnowledge knowledge, String name, ItemStack icon) {
|
||||||
|
Row(BlockKnowledge knowledge) {
|
||||||
|
this(knowledge, block(knowledge).map(value -> value.getName().getString()).orElse(knowledge.blockId()),
|
||||||
|
block(knowledge).map(value -> new ItemStack(value.asItem())).orElse(ItemStack.EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Block> block(BlockKnowledge knowledge) {
|
||||||
|
return BuiltInRegistries.BLOCK.getOptional(Identifier.parse(knowledge.blockId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class EntryList extends ObjectSelectionList<Entry> {
|
||||||
|
EntryList(int listWidth, int listHeight, int top) {
|
||||||
|
super(SanctuaryBlocodexScreen.this.minecraft, listWidth, listHeight, top, ROW_HEIGHT);
|
||||||
|
centerListVertically = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getRowWidth() { return getWidth() - 12; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Entry extends ObjectSelectionList.Entry<Entry> {
|
||||||
|
private final Row row;
|
||||||
|
|
||||||
|
Entry(Row row) { this.row = row; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Component getNarration() {
|
||||||
|
BlockKnowledge knowledge = row.knowledge();
|
||||||
|
return label("narration", row.name(), label(knowledge.known() ? "known" : "unknown"),
|
||||||
|
yesNo(knowledge.seen()), knowledge.mined(), yesNo(knowledge.possessed()),
|
||||||
|
knowledge.dropped(), knowledge.placed(), knowledge.carried());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void extractContent(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float partialTick) {
|
||||||
|
BlockKnowledge knowledge = row.knowledge();
|
||||||
|
int x = getContentX();
|
||||||
|
int y = getContentY();
|
||||||
|
int contentWidth = getContentWidth();
|
||||||
|
int color = knowledge.known() ? TEXT : MUTED;
|
||||||
|
if (!row.icon().isEmpty()) graphics.item(row.icon(), x, y);
|
||||||
|
Component state = label(knowledge.known() ? "known" : "unknown");
|
||||||
|
int stateWidth = font.width(state);
|
||||||
|
graphics.text(font, font.plainSubstrByWidth(row.name(), Math.max(24, contentWidth - stateWidth - 30)), x + 22, y + 3, color);
|
||||||
|
graphics.text(font, state, x + contentWidth - stateWidth, y + 3, knowledge.known() ? 0xFF91CF9B : MUTED);
|
||||||
|
int cellWidth = contentWidth / 3;
|
||||||
|
drawCell(graphics, label("seen", yesNo(knowledge.seen())), x, y + 18, cellWidth, color);
|
||||||
|
drawCell(graphics, label("mined", compact(knowledge.mined())), x + cellWidth, y + 18, cellWidth, color);
|
||||||
|
drawCell(graphics, label("possessed", yesNo(knowledge.possessed())), x + cellWidth * 2, y + 18, cellWidth, color);
|
||||||
|
drawCell(graphics, label("dropped", compact(knowledge.dropped())), x, y + 29, cellWidth, color);
|
||||||
|
drawCell(graphics, label("placed", compact(knowledge.placed())), x + cellWidth, y + 29, cellWidth, color);
|
||||||
|
drawCell(graphics, label("carried", compact(knowledge.carried())), x + cellWidth * 2, y + 29, cellWidth, color);
|
||||||
|
if (hovered) graphics.setComponentTooltipForNextFrame(font, List.of(Component.literal(row.name()),
|
||||||
|
Component.literal(knowledge.blockId()), label("seen", yesNo(knowledge.seen())),
|
||||||
|
label("mined", knowledge.mined()), label("possessed", yesNo(knowledge.possessed())),
|
||||||
|
label("dropped", knowledge.dropped()), label("placed", knowledge.placed()),
|
||||||
|
label("carried", knowledge.carried())), mouseX, mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawCell(GuiGraphicsExtractor graphics, Component text, int x, int y, int cellWidth, int color) {
|
||||||
|
graphics.text(font, font.plainSubstrByWidth(text.getString(), cellWidth - 3), x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Filter {
|
||||||
|
ALL, UNKNOWN, KNOWN, SEEN, MINED, POSSESSED, DROPPED, PLACED, CARRIED;
|
||||||
|
|
||||||
|
Component label() { return SanctuaryBlocodexScreen.label("filter." + name().toLowerCase(Locale.ROOT)); }
|
||||||
|
|
||||||
|
boolean matches(BlockKnowledge knowledge) {
|
||||||
|
return switch (this) {
|
||||||
|
case ALL -> true;
|
||||||
|
case UNKNOWN -> !knowledge.known();
|
||||||
|
case KNOWN -> knowledge.known();
|
||||||
|
case SEEN -> knowledge.seen();
|
||||||
|
case MINED -> knowledge.mined() > 0;
|
||||||
|
case POSSESSED -> knowledge.possessed();
|
||||||
|
case DROPPED -> knowledge.dropped() > 0;
|
||||||
|
case PLACED -> knowledge.placed() > 0;
|
||||||
|
case CARRIED -> knowledge.carried() > 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class HelpScreen extends Screen {
|
||||||
|
private final Screen parent;
|
||||||
|
|
||||||
|
HelpScreen(Screen parent) {
|
||||||
|
super(label("help"));
|
||||||
|
this.parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void init() {
|
||||||
|
LinearLayout contents = LinearLayout.vertical();
|
||||||
|
contents.addChild(new MultiLineTextWidget(label("help_text"), font).setMaxWidth(Math.min(450, width - 48)));
|
||||||
|
ScrollableLayout scroll = new ScrollableLayout(minecraft, contents, Math.max(40, height - 80));
|
||||||
|
scroll.arrangeElements();
|
||||||
|
scroll.setX((width - scroll.getWidth()) / 2);
|
||||||
|
scroll.setY(38);
|
||||||
|
scroll.visitWidgets(this::addRenderableWidget);
|
||||||
|
addRenderableWidget(Button.builder(CommonComponents.GUI_BACK, button -> onClose())
|
||||||
|
.bounds(width / 2 - 75, height - 30, 150, 20).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||||
|
graphics.centeredText(font, title, width / 2, 14, TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClose() { minecraft.gui.setScreen(parent); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isPauseScreen() { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package fr.koka.sanctuary.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgePayloads;
|
||||||
|
import net.fabricmc.api.ClientModInitializer;
|
||||||
|
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||||
|
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
|
||||||
|
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||||
|
import net.minecraft.client.KeyMapping;
|
||||||
|
|
||||||
|
/** Innate access: no recipe, item, operator permission or external mod. */
|
||||||
|
public final class SanctuaryKnowledgeClient implements ClientModInitializer {
|
||||||
|
@Override
|
||||||
|
public void onInitializeClient() {
|
||||||
|
registerClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void registerClient() {
|
||||||
|
KeyMapping open = KeyMappingHelper.registerKeyMapping(new KeyMapping("sanctuary.blocodex.key",
|
||||||
|
InputConstants.Type.KEYBOARD, InputConstants.KEY_B,
|
||||||
|
KeyMapping.Category.register(SanctuaryMod.id("sanctuary"))));
|
||||||
|
ClientPlayNetworking.registerGlobalReceiver(BlockKnowledgePayloads.SnapshotPart.TYPE, (part, context) ->
|
||||||
|
context.client().execute(() -> {
|
||||||
|
if (context.client().gui.screen() instanceof SanctuaryBlocodexScreen screen) screen.accept(part);
|
||||||
|
}));
|
||||||
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
|
while (open.consumeClick()) {
|
||||||
|
if (client.player != null && client.gui.screen() == null) {
|
||||||
|
client.gui.setScreen(new SanctuaryBlocodexScreen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
/** A server-side snapshot; mined and dropped are read from vanilla statistics. */
|
||||||
|
public record BlockKnowledge(String blockId, boolean seen, boolean possessed,
|
||||||
|
long mined, long dropped, long placed, int carried) {
|
||||||
|
public BlockKnowledge {
|
||||||
|
BlockKnowledgeMemory.requireBlockId(blockId);
|
||||||
|
if (mined < 0 || dropped < 0 || placed < 0 || carried < 0) {
|
||||||
|
throw new IllegalArgumentException("Block knowledge counters cannot be negative");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown is the absence of all evidence, rather than another persisted flag. */
|
||||||
|
public boolean known() {
|
||||||
|
return seen || possessed || mined > 0 || dropped > 0 || placed > 0 || carried > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import net.fabricmc.fabric.api.event.Event;
|
||||||
|
import net.fabricmc.fabric.api.event.EventFactory;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/** Server-thread notifications for future consumers; not a durable reward ledger. */
|
||||||
|
public final class BlockKnowledgeEvents {
|
||||||
|
public enum Activity { SEEN, POSSESSED, MINED, DROPPED, PLACED, INVENTORY }
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Changed {
|
||||||
|
void onChanged(ServerPlayer player, BlockKnowledge knowledge, Activity activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Event<Changed> CHANGED = EventFactory.createArrayBacked(Changed.class,
|
||||||
|
listeners -> (player, knowledge, activity) -> {
|
||||||
|
for (Changed listener : listeners) listener.onChanged(player, knowledge, activity);
|
||||||
|
});
|
||||||
|
|
||||||
|
private BlockKnowledgeEvents() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
/** Independent evidence predicates, composable with {@link Predicate#and} and {@link Predicate#or}. */
|
||||||
|
public enum BlockKnowledgeFacet implements Predicate<BlockKnowledge> {
|
||||||
|
KNOWN(BlockKnowledge::known),
|
||||||
|
SEEN(BlockKnowledge::seen),
|
||||||
|
MINED(knowledge -> knowledge.mined() > 0),
|
||||||
|
POSSESSED(BlockKnowledge::possessed),
|
||||||
|
DROPPED(knowledge -> knowledge.dropped() > 0),
|
||||||
|
PLACED(knowledge -> knowledge.placed() > 0),
|
||||||
|
CARRIED(knowledge -> knowledge.carried() > 0);
|
||||||
|
|
||||||
|
private final Predicate<BlockKnowledge> evidence;
|
||||||
|
|
||||||
|
BlockKnowledgeFacet(Predicate<BlockKnowledge> evidence) {
|
||||||
|
this.evidence = evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean test(BlockKnowledge knowledge) {
|
||||||
|
return evidence.test(knowledge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Only Sanctuary's evidence is stored here; vanilla statistics keep their ownership. */
|
||||||
|
public final class BlockKnowledgeMemory {
|
||||||
|
private static final Pattern BLOCK_ID = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||||
|
private static final Entry UNKNOWN = new Entry(false, false, 0);
|
||||||
|
private final Map<String, Entry> entries = new HashMap<>();
|
||||||
|
|
||||||
|
public BlockKnowledgeMemory() { }
|
||||||
|
|
||||||
|
/** Restores stable identifiers without requiring the corresponding mod to be installed. */
|
||||||
|
public static BlockKnowledgeMemory restored(Map<String, Entry> saved) {
|
||||||
|
Objects.requireNonNull(saved, "saved");
|
||||||
|
BlockKnowledgeMemory memory = new BlockKnowledgeMemory();
|
||||||
|
saved.forEach((id, entry) -> {
|
||||||
|
requireBlockId(id);
|
||||||
|
memory.entries.put(id, Objects.requireNonNull(entry, "entry"));
|
||||||
|
});
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean recordSeen(String blockId) {
|
||||||
|
Entry previous = entry(blockId);
|
||||||
|
if (previous.seen()) return false;
|
||||||
|
entries.put(blockId, new Entry(true, previous.possessed(), previous.placed()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean recordPossessed(String blockId) {
|
||||||
|
Entry previous = entry(blockId);
|
||||||
|
if (previous.possessed()) return false;
|
||||||
|
entries.put(blockId, new Entry(previous.seen(), true, previous.placed()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void recordPlaced(String blockId) {
|
||||||
|
Entry previous = entry(blockId);
|
||||||
|
long placed = previous.placed() == Long.MAX_VALUE ? Long.MAX_VALUE : previous.placed() + 1;
|
||||||
|
entries.put(blockId, new Entry(previous.seen(), previous.possessed(), placed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Entry entry(String blockId) {
|
||||||
|
requireBlockId(blockId);
|
||||||
|
return entries.getOrDefault(blockId, UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable immutable snapshot, safe to expose to other mechanics. */
|
||||||
|
public synchronized Map<String, Entry> entries() {
|
||||||
|
return Map.copyOf(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireBlockId(String blockId) {
|
||||||
|
if (blockId == null || !BLOCK_ID.matcher(blockId).matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid namespaced block identifier: " + blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Entry(boolean seen, boolean possessed, long placed) {
|
||||||
|
public Entry {
|
||||||
|
if (placed < 0) throw new IllegalArgumentException("Placed count cannot be negative");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/** The client can only ask for its own server-maintained state. */
|
||||||
|
public final class BlockKnowledgeNetworking {
|
||||||
|
private static final Map<ServerPlayer, Long> LAST_REQUEST = new WeakHashMap<>();
|
||||||
|
private static final long REQUEST_INTERVAL_NANOS = 1_000_000_000L;
|
||||||
|
|
||||||
|
private BlockKnowledgeNetworking() { }
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
PayloadTypeRegistry.serverboundPlay().register(BlockKnowledgePayloads.Request.TYPE, BlockKnowledgePayloads.Request.CODEC);
|
||||||
|
PayloadTypeRegistry.clientboundPlay().register(BlockKnowledgePayloads.SnapshotPart.TYPE, BlockKnowledgePayloads.SnapshotPart.CODEC);
|
||||||
|
ServerPlayNetworking.registerGlobalReceiver(BlockKnowledgePayloads.Request.TYPE, (request, context) ->
|
||||||
|
context.server().execute(() -> respond(context.player(), request.requestId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respond(ServerPlayer player, int requestId) {
|
||||||
|
if (!ServerPlayNetworking.canSend(player, BlockKnowledgePayloads.SnapshotPart.TYPE)) return;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
synchronized (LAST_REQUEST) {
|
||||||
|
Long previous = LAST_REQUEST.get(player);
|
||||||
|
if (previous != null && now - previous < REQUEST_INTERVAL_NANOS) return;
|
||||||
|
LAST_REQUEST.put(player, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!BlockKnowledgeService.healthy(player)) {
|
||||||
|
unavailable(player, requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<BlockKnowledge> snapshot = List.copyOf(BlockKnowledgeService.snapshot(player));
|
||||||
|
if (snapshot.size() > BlockKnowledgePayloads.MAX_ENTRIES) {
|
||||||
|
unavailable(player, requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validate before sending any fragment: a snapshot is all-or-nothing at the client.
|
||||||
|
snapshot.forEach(BlockKnowledgePayloads::validateEntry);
|
||||||
|
int parts = Math.max(1, (snapshot.size() + BlockKnowledgePayloads.FRAGMENT_SIZE - 1)
|
||||||
|
/ BlockKnowledgePayloads.FRAGMENT_SIZE);
|
||||||
|
for (int part = 0; part < parts; part++) {
|
||||||
|
int start = part * BlockKnowledgePayloads.FRAGMENT_SIZE;
|
||||||
|
int end = Math.min(start + BlockKnowledgePayloads.FRAGMENT_SIZE, snapshot.size());
|
||||||
|
ServerPlayNetworking.send(player, new BlockKnowledgePayloads.SnapshotPart(requestId, part, parts,
|
||||||
|
snapshot.size(), true, snapshot.subList(start, end)));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
SanctuaryMod.LOGGER.error("Unable to send Blocodex snapshot", failure);
|
||||||
|
unavailable(player, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void unavailable(ServerPlayer player, int requestId) {
|
||||||
|
ServerPlayNetworking.send(player, new BlockKnowledgePayloads.SnapshotPart(requestId, 0, 1, 0, false, List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/** Read-only requests and bounded fragments of a single server snapshot. */
|
||||||
|
public final class BlockKnowledgePayloads {
|
||||||
|
public static final int FRAGMENT_SIZE = 128;
|
||||||
|
public static final int MAX_ENTRIES = 32_768;
|
||||||
|
public static final int MAX_ID_LENGTH = 256;
|
||||||
|
|
||||||
|
private BlockKnowledgePayloads() { }
|
||||||
|
|
||||||
|
public record Request(int requestId) implements CustomPacketPayload {
|
||||||
|
public static final Type<Request> TYPE = new Type<>(SanctuaryMod.id("blocodex_request"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, Request> CODEC =
|
||||||
|
CustomPacketPayload.codec((request, buffer) -> buffer.writeVarInt(request.requestId()),
|
||||||
|
buffer -> new Request(buffer.readVarInt()));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<Request> type() { return TYPE; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SnapshotPart(int requestId, int partIndex, int partCount, int totalEntries,
|
||||||
|
boolean healthy, List<BlockKnowledge> entries) implements CustomPacketPayload {
|
||||||
|
public static final Type<SnapshotPart> TYPE = new Type<>(SanctuaryMod.id("blocodex_snapshot"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, SnapshotPart> CODEC =
|
||||||
|
CustomPacketPayload.codec(SnapshotPart::write, SnapshotPart::read);
|
||||||
|
|
||||||
|
public SnapshotPart {
|
||||||
|
entries = List.copyOf(entries);
|
||||||
|
if (totalEntries < 0 || totalEntries > MAX_ENTRIES
|
||||||
|
|| partCount != Math.max(1, (totalEntries + FRAGMENT_SIZE - 1) / FRAGMENT_SIZE)
|
||||||
|
|| partIndex < 0 || partIndex >= partCount
|
||||||
|
|| entries.size() != Math.min(FRAGMENT_SIZE, totalEntries - partIndex * FRAGMENT_SIZE)
|
||||||
|
|| (!healthy && totalEntries != 0)) {
|
||||||
|
throw new IllegalArgumentException("Invalid Blocodex snapshot fragment");
|
||||||
|
}
|
||||||
|
for (BlockKnowledge entry : entries) validateEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<SnapshotPart> type() { return TYPE; }
|
||||||
|
|
||||||
|
private void write(RegistryFriendlyByteBuf buffer) {
|
||||||
|
buffer.writeVarInt(requestId);
|
||||||
|
buffer.writeVarInt(partIndex);
|
||||||
|
buffer.writeVarInt(partCount);
|
||||||
|
buffer.writeVarInt(totalEntries);
|
||||||
|
buffer.writeBoolean(healthy);
|
||||||
|
buffer.writeVarInt(entries.size());
|
||||||
|
for (BlockKnowledge entry : entries) {
|
||||||
|
buffer.writeUtf(entry.blockId(), MAX_ID_LENGTH);
|
||||||
|
buffer.writeBoolean(entry.seen());
|
||||||
|
buffer.writeBoolean(entry.possessed());
|
||||||
|
buffer.writeVarLong(entry.mined());
|
||||||
|
buffer.writeVarLong(entry.dropped());
|
||||||
|
buffer.writeVarLong(entry.placed());
|
||||||
|
buffer.writeVarInt(entry.carried());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SnapshotPart read(RegistryFriendlyByteBuf buffer) {
|
||||||
|
int request = buffer.readVarInt();
|
||||||
|
int index = buffer.readVarInt();
|
||||||
|
int parts = buffer.readVarInt();
|
||||||
|
int total = buffer.readVarInt();
|
||||||
|
boolean healthy = buffer.readBoolean();
|
||||||
|
int size = buffer.readVarInt();
|
||||||
|
if (size < 0 || size > FRAGMENT_SIZE) throw new IllegalArgumentException("Oversized Blocodex fragment");
|
||||||
|
List<BlockKnowledge> entries = new ArrayList<>(size);
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
entries.add(new BlockKnowledge(buffer.readUtf(MAX_ID_LENGTH), buffer.readBoolean(), buffer.readBoolean(),
|
||||||
|
buffer.readVarLong(), buffer.readVarLong(), buffer.readVarLong(), buffer.readVarInt()));
|
||||||
|
}
|
||||||
|
return new SnapshotPart(request, index, parts, total, healthy, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateEntry(BlockKnowledge entry) {
|
||||||
|
if (entry.blockId().length() > MAX_ID_LENGTH || Identifier.tryParse(entry.blockId()) == null
|
||||||
|
|| entry.mined() < 0 || entry.dropped() < 0 || entry.placed() < 0 || entry.carried() < 0) {
|
||||||
|
throw new IllegalArgumentException("Invalid Blocodex entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.stats.Stat;
|
||||||
|
import net.minecraft.stats.Stats;
|
||||||
|
import net.minecraft.tags.TagKey;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.ClipContext;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
import net.minecraft.world.phys.HitResult;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal, world-local knowledge. All mutations and reads run on the server thread.
|
||||||
|
* Vanilla owns mined/dropped counters; Sanctuary owns seen/possessed/placed history.
|
||||||
|
*/
|
||||||
|
public final class BlockKnowledgeService {
|
||||||
|
public static final int OBSERVATION_INTERVAL = 5;
|
||||||
|
public static final int SAVE_INTERVAL = 600;
|
||||||
|
public static final double OBSERVATION_DISTANCE = 32;
|
||||||
|
public static final TagKey<Block> NATURAL_STRUCTURE_MATERIALS = TagKey.create(Registries.BLOCK,
|
||||||
|
SanctuaryMod.id("structure_palette/natural"));
|
||||||
|
private static final Map<MinecraftServer, Map<UUID, Session>> SESSIONS = new IdentityHashMap<>();
|
||||||
|
private static List<Block> catalog;
|
||||||
|
|
||||||
|
private BlockKnowledgeService() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> observeInventory(handler.player));
|
||||||
|
// Fabric also reaches DISCONNECT through Netty's channelInactive callback.
|
||||||
|
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> server.execute(() -> {
|
||||||
|
Map<UUID, Session> sessions = SESSIONS.get(server);
|
||||||
|
if (sessions == null) return;
|
||||||
|
Session session = sessions.remove(handler.player.getUUID());
|
||||||
|
if (session != null) save(session);
|
||||||
|
}));
|
||||||
|
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||||
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||||
|
observeInventory(player);
|
||||||
|
if (Math.floorMod(server.getTickCount() + player.getId(), OBSERVATION_INTERVAL) == 0) observeLook(player);
|
||||||
|
}
|
||||||
|
if (server.getTickCount() % SAVE_INTERVAL == 0) flush(server);
|
||||||
|
});
|
||||||
|
ServerLifecycleEvents.BEFORE_SAVE.register((server, flush, force) -> flush(server));
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPING.register(BlockKnowledgeService::flush);
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPED.register(SESSIONS::remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed history is explicitly unavailable, never an empty progression record. */
|
||||||
|
public static boolean healthy(ServerPlayer player) { return !session(player).failed; }
|
||||||
|
|
||||||
|
public static List<BlockKnowledge> snapshot(ServerPlayer player) {
|
||||||
|
observeInventory(player);
|
||||||
|
Session session = requireHealthy(player);
|
||||||
|
List<BlockKnowledge> result = new ArrayList<>(catalog().size());
|
||||||
|
for (Block block : catalog()) result.add(value(player, block, session));
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BlockKnowledge knowledge(ServerPlayer player, Block block) {
|
||||||
|
observeInventory(player);
|
||||||
|
return value(player, block, requireHealthy(player));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate materials for structures: combine knowledge facets with a data-pack material tag.
|
||||||
|
* Retains current quantities but does not reserve/consume them or authorize world placement.
|
||||||
|
*/
|
||||||
|
public static List<BlockKnowledge> palette(ServerPlayer player, Predicate<BlockKnowledge> condition, TagKey<Block> materials) {
|
||||||
|
Objects.requireNonNull(condition, "condition");
|
||||||
|
Objects.requireNonNull(materials, "materials");
|
||||||
|
return snapshot(player).stream().filter(condition)
|
||||||
|
.filter(entry -> BuiltInRegistries.BLOCK.getValue(Identifier.parse(entry.blockId())).defaultBlockState().is(materials))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void recordSeen(ServerPlayer player, Block block) {
|
||||||
|
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
|
||||||
|
Session session = session(player);
|
||||||
|
if (session.failed || !session.memory.recordSeen(id(block))) return;
|
||||||
|
session.dirty = true;
|
||||||
|
changed(player, block, session, BlockKnowledgeEvents.Activity.SEEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called after successful BlockItem placement, before its item is consumed. */
|
||||||
|
public static void recordPlaced(ServerPlayer player, Block block) {
|
||||||
|
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
|
||||||
|
observeInventory(player);
|
||||||
|
Session session = session(player);
|
||||||
|
if (session.failed) return;
|
||||||
|
session.memory.recordPlaced(id(block));
|
||||||
|
session.dirty = true;
|
||||||
|
changed(player, block, session, BlockKnowledgeEvents.Activity.PLACED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Observes only personal slots and the carried cursor, never an open chest's contents. */
|
||||||
|
public static void observeInventory(ServerPlayer player) {
|
||||||
|
Session session = session(player);
|
||||||
|
if (session.failed) return;
|
||||||
|
Map<Block, Integer> current = inventory(player);
|
||||||
|
Map<Block, Integer> previous = session.carried;
|
||||||
|
session.carried = current;
|
||||||
|
for (Block block : current.keySet()) {
|
||||||
|
if (session.memory.recordPossessed(id(block))) {
|
||||||
|
session.dirty = true;
|
||||||
|
changed(player, block, session, BlockKnowledgeEvents.Activity.POSSESSED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!current.equals(previous)) {
|
||||||
|
var changedBlocks = new HashSet<>(previous.keySet());
|
||||||
|
changedBlocks.addAll(current.keySet());
|
||||||
|
for (Block block : changedBlocks) {
|
||||||
|
if (!current.getOrDefault(block, 0).equals(previous.getOrDefault(block, 0))) {
|
||||||
|
changed(player, block, session, BlockKnowledgeEvents.Activity.INVENTORY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called after vanilla updates a stat; does not award or mirror its counter. */
|
||||||
|
public static void statChanged(ServerPlayer player, Stat<?> stat) {
|
||||||
|
Block block;
|
||||||
|
BlockKnowledgeEvents.Activity activity;
|
||||||
|
if (stat.getType() == Stats.BLOCK_MINED && stat.getValue() instanceof Block mined) {
|
||||||
|
block = mined;
|
||||||
|
activity = BlockKnowledgeEvents.Activity.MINED;
|
||||||
|
} else if ((stat.getType() == Stats.ITEM_DROPPED || stat.getType() == Stats.ITEM_PICKED_UP)
|
||||||
|
&& stat.getValue() instanceof BlockItem item) {
|
||||||
|
block = item.getBlock();
|
||||||
|
activity = stat.getType() == Stats.ITEM_DROPPED ? BlockKnowledgeEvents.Activity.DROPPED : BlockKnowledgeEvents.Activity.POSSESSED;
|
||||||
|
} else return;
|
||||||
|
Session session = session(player);
|
||||||
|
if (session.failed || block.defaultBlockState().isAir()) return;
|
||||||
|
observeInventory(player);
|
||||||
|
if (stat.getType() != Stats.BLOCK_MINED && player.getStats().getValue(stat) > 0
|
||||||
|
&& session.memory.recordPossessed(id(block))) {
|
||||||
|
session.dirty = true;
|
||||||
|
changed(player, block, session, BlockKnowledgeEvents.Activity.POSSESSED);
|
||||||
|
}
|
||||||
|
if (activity != BlockKnowledgeEvents.Activity.POSSESSED) changed(player, block, session, activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single occluded gaze ray, with no requests for missing chunks. */
|
||||||
|
public static void observeLook(ServerPlayer player) {
|
||||||
|
if (player.isSpectator() || !player.isAlive() || !healthy(player)) return;
|
||||||
|
Vec3 start = player.getEyePosition();
|
||||||
|
Vec3 end = start.add(player.getLookAngle().scale(OBSERVATION_DISTANCE));
|
||||||
|
if (!finite(start) || !finite(end)) return;
|
||||||
|
// clip's shape queries may inspect neighbours. Reject absent neighbouring chunks too.
|
||||||
|
int minX = ((int) Math.floor(Math.min(start.x, end.x) - 1)) >> 4;
|
||||||
|
int maxX = ((int) Math.floor(Math.max(start.x, end.x) + 1)) >> 4;
|
||||||
|
int minZ = ((int) Math.floor(Math.min(start.z, end.z) - 1)) >> 4;
|
||||||
|
int maxZ = ((int) Math.floor(Math.max(start.z, end.z) + 1)) >> 4;
|
||||||
|
for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) {
|
||||||
|
if (!player.level().getChunkSource().hasChunk(x, z)) return;
|
||||||
|
}
|
||||||
|
var hit = player.level().clip(new ClipContext(start, end, ClipContext.Block.OUTLINE, ClipContext.Fluid.ANY, player));
|
||||||
|
if (hit.getType() == HitResult.Type.BLOCK) recordSeen(player, player.level().getBlockState(hit.getBlockPos()).getBlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void flush(MinecraftServer server) {
|
||||||
|
Map<UUID, Session> sessions = SESSIONS.get(server);
|
||||||
|
if (sessions != null) sessions.values().forEach(BlockKnowledgeService::save);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockKnowledge value(ServerPlayer player, Block block, Session session) {
|
||||||
|
var history = session.memory.entry(id(block));
|
||||||
|
var stats = player.getStats();
|
||||||
|
long mined = Math.max(0, stats.getValue(Stats.BLOCK_MINED, block));
|
||||||
|
long dropped = 0;
|
||||||
|
boolean possessed = history.possessed();
|
||||||
|
// Shared block items (e.g. wall/standing torch) must not duplicate possession/drop totals.
|
||||||
|
if (block.asItem() instanceof BlockItem item && item.getBlock() == block) {
|
||||||
|
dropped = Math.max(0, stats.getValue(Stats.ITEM_DROPPED, item));
|
||||||
|
possessed |= dropped > 0 || stats.getValue(Stats.ITEM_PICKED_UP, item) > 0;
|
||||||
|
}
|
||||||
|
return new BlockKnowledge(id(block), history.seen(), possessed, mined, dropped, history.placed(), session.carried.getOrDefault(block, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<Block, Integer> inventory(ServerPlayer player) {
|
||||||
|
Map<Block, Integer> result = new HashMap<>();
|
||||||
|
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) add(result, player.getInventory().getItem(slot));
|
||||||
|
if (player.containerMenu != null) add(result, player.containerMenu.getCarried());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(Map<Block, Integer> result, ItemStack stack) {
|
||||||
|
if (!stack.isEmpty() && stack.getItem() instanceof BlockItem item && !item.getBlock().defaultBlockState().isAir()) {
|
||||||
|
result.merge(item.getBlock(), stack.getCount(), (left, right) -> (int) Math.min(Integer.MAX_VALUE, (long) left + right));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void changed(ServerPlayer player, Block block, Session session, BlockKnowledgeEvents.Activity activity) {
|
||||||
|
BlockKnowledgeEvents.CHANGED.invoker().onChanged(player, value(player, block, session), activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Session requireHealthy(ServerPlayer player) {
|
||||||
|
Session session = session(player);
|
||||||
|
if (session.failed) throw new IllegalStateException("Sanctuary block knowledge is unavailable for " + player.getUUID());
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Session session(ServerPlayer player) {
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (!server.isSameThread()) throw new IllegalStateException("Block knowledge must be accessed on the server thread");
|
||||||
|
return SESSIONS.computeIfAbsent(server, ignored -> new HashMap<>()).computeIfAbsent(player.getUUID(), uuid -> {
|
||||||
|
var store = new BlockKnowledgeStore(server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-blocodex"), uuid);
|
||||||
|
Session session = new Session(store);
|
||||||
|
try { session.memory = store.load(); }
|
||||||
|
catch (IOException exception) {
|
||||||
|
session.failed = true;
|
||||||
|
SanctuaryMod.LOGGER.error("Cannot load block knowledge for {}; existing history will not be replaced", uuid, exception);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void save(Session session) {
|
||||||
|
if (!session.dirty || session.failed) return;
|
||||||
|
try {
|
||||||
|
session.store.save(session.memory);
|
||||||
|
session.dirty = false;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
session.failed = true;
|
||||||
|
SanctuaryMod.LOGGER.error("Cannot save block knowledge; this history is unavailable until a successful reload", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Block> catalog() {
|
||||||
|
if (catalog == null) catalog = BuiltInRegistries.BLOCK.stream().filter(block -> !block.defaultBlockState().isAir())
|
||||||
|
.sorted(Comparator.comparing(BlockKnowledgeService::id)).toList();
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String id(Block block) { return BuiltInRegistries.BLOCK.getKey(block).toString(); }
|
||||||
|
private static boolean finite(Vec3 vector) { return Double.isFinite(vector.x) && Double.isFinite(vector.y) && Double.isFinite(vector.z); }
|
||||||
|
|
||||||
|
private static final class Session {
|
||||||
|
final BlockKnowledgeStore store;
|
||||||
|
BlockKnowledgeMemory memory;
|
||||||
|
Map<Block, Integer> carried = Map.of();
|
||||||
|
boolean dirty;
|
||||||
|
boolean failed;
|
||||||
|
Session(BlockKnowledgeStore store) { this.store = store; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.Strictness;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonToken;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Additive per-player sidecar. A failed session cannot replace its existing evidence. */
|
||||||
|
public final class BlockKnowledgeStore {
|
||||||
|
private static final int MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
private static final int MAX_BLOCKS = 100_000;
|
||||||
|
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
private static final Set<String> ROOT_FIELDS = Set.of("schema", "player", "blocks");
|
||||||
|
private static final Set<String> ENTRY_FIELDS = Set.of("seen", "possessed", "placed");
|
||||||
|
private final Path directory;
|
||||||
|
private final Path file;
|
||||||
|
private final UUID player;
|
||||||
|
private boolean loaded;
|
||||||
|
private boolean failed;
|
||||||
|
private byte[] previousBytes;
|
||||||
|
private Map<String, BlockKnowledgeMemory.Entry> previousEntries;
|
||||||
|
|
||||||
|
public BlockKnowledgeStore(Path directory, UUID player) {
|
||||||
|
this.directory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize();
|
||||||
|
this.player = Objects.requireNonNull(player, "player");
|
||||||
|
this.file = this.directory.resolve(player + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Missing state starts in memory only. Invalid state requires repair and a new store instance. */
|
||||||
|
public synchronized BlockKnowledgeMemory load() throws IOException {
|
||||||
|
if (failed) throw new IOException("Block knowledge storage failed earlier; inspect it before reloading");
|
||||||
|
try {
|
||||||
|
checkDirectory();
|
||||||
|
byte[] bytes = readBytes();
|
||||||
|
Map<String, BlockKnowledgeMemory.Entry> entries = bytes == null ? Map.of() : decode(bytes);
|
||||||
|
previousBytes = bytes;
|
||||||
|
previousEntries = entries;
|
||||||
|
loaded = true;
|
||||||
|
return BlockKnowledgeMemory.restored(entries);
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
failed = true;
|
||||||
|
throw storageError("Could not load block knowledge", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void save(BlockKnowledgeMemory memory) throws IOException {
|
||||||
|
if (failed || !loaded) throw new IOException("Block knowledge must load successfully before saving");
|
||||||
|
Path temporary = null;
|
||||||
|
try {
|
||||||
|
Map<String, BlockKnowledgeMemory.Entry> entries = Objects.requireNonNull(memory, "memory").entries();
|
||||||
|
checkDirectory();
|
||||||
|
if (!Arrays.equals(previousBytes, readBytes())) {
|
||||||
|
throw new IOException("Block knowledge changed outside this store; refusing to replace it");
|
||||||
|
}
|
||||||
|
if (previousEntries.equals(entries)) return;
|
||||||
|
byte[] bytes = encode(entries);
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
temporary = Files.createTempFile(directory, "." + player + "-", ".tmp");
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||||
|
while (buffer.hasRemaining()) channel.write(buffer);
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
// Unsupported atomic replacement fails explicitly; no unsafe fallback is used.
|
||||||
|
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
temporary = null;
|
||||||
|
forceDirectory();
|
||||||
|
byte[] verified = readBytes();
|
||||||
|
if (!Arrays.equals(bytes, verified) || !decode(verified).equals(entries)) {
|
||||||
|
throw new IOException("Block knowledge read-back differs from its saved snapshot");
|
||||||
|
}
|
||||||
|
previousBytes = verified;
|
||||||
|
previousEntries = entries;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
failed = true;
|
||||||
|
throw storageError("Could not save block knowledge", error);
|
||||||
|
} finally {
|
||||||
|
if (temporary != null) {
|
||||||
|
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* Preserve the original failure. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkDirectory() throws IOException {
|
||||||
|
if (!Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||||
|
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("Block knowledge path is not a regular directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] readBytes() throws IOException {
|
||||||
|
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return null;
|
||||||
|
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("Block knowledge is not a regular file");
|
||||||
|
}
|
||||||
|
if (Files.size(file) > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
|
||||||
|
try (var input = Files.newInputStream(file)) {
|
||||||
|
byte[] bytes = input.readNBytes(MAX_BYTES + 1);
|
||||||
|
if (bytes.length > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, BlockKnowledgeMemory.Entry> decode(byte[] bytes) throws IOException {
|
||||||
|
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
try (JsonReader reader = new JsonReader(new StringReader(text))) {
|
||||||
|
reader.setStrictness(Strictness.STRICT);
|
||||||
|
requireToken(reader, JsonToken.BEGIN_OBJECT);
|
||||||
|
reader.beginObject();
|
||||||
|
Set<String> fields = new HashSet<>();
|
||||||
|
Map<String, BlockKnowledgeMemory.Entry> entries = null;
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
String field = field(reader, fields, ROOT_FIELDS);
|
||||||
|
switch (field) {
|
||||||
|
case "schema" -> {
|
||||||
|
if (nonnegativeLong(reader) != 1) throw new IOException("Unsupported block knowledge schema");
|
||||||
|
}
|
||||||
|
case "player" -> {
|
||||||
|
requireToken(reader, JsonToken.STRING);
|
||||||
|
if (!reader.nextString().equals(player.toString())) throw new IOException("Block knowledge player UUID differs");
|
||||||
|
}
|
||||||
|
case "blocks" -> entries = readEntries(reader);
|
||||||
|
default -> throw new IOException("Unknown block knowledge field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
if (!fields.equals(ROOT_FIELDS)) throw new IOException("Missing block knowledge fields");
|
||||||
|
requireToken(reader, JsonToken.END_DOCUMENT);
|
||||||
|
return Map.copyOf(entries);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw new IOException("Invalid block knowledge JSON", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, BlockKnowledgeMemory.Entry> readEntries(JsonReader reader) throws IOException {
|
||||||
|
requireToken(reader, JsonToken.BEGIN_OBJECT);
|
||||||
|
reader.beginObject();
|
||||||
|
Map<String, BlockKnowledgeMemory.Entry> entries = new HashMap<>();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
if (entries.size() >= MAX_BLOCKS) throw new IOException("Too many block knowledge entries");
|
||||||
|
String blockId = reader.nextName();
|
||||||
|
BlockKnowledgeMemory.requireBlockId(blockId);
|
||||||
|
if (entries.containsKey(blockId)) throw new IOException("Duplicate block knowledge identifier");
|
||||||
|
requireToken(reader, JsonToken.BEGIN_OBJECT);
|
||||||
|
reader.beginObject();
|
||||||
|
Set<String> fields = new HashSet<>();
|
||||||
|
boolean seen = false;
|
||||||
|
boolean possessed = false;
|
||||||
|
long placed = 0;
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
switch (field(reader, fields, ENTRY_FIELDS)) {
|
||||||
|
case "seen" -> { requireToken(reader, JsonToken.BOOLEAN); seen = reader.nextBoolean(); }
|
||||||
|
case "possessed" -> { requireToken(reader, JsonToken.BOOLEAN); possessed = reader.nextBoolean(); }
|
||||||
|
case "placed" -> placed = nonnegativeLong(reader);
|
||||||
|
default -> throw new IOException("Unknown block knowledge entry field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
if (!fields.equals(ENTRY_FIELDS)) throw new IOException("Missing block knowledge entry fields");
|
||||||
|
entries.put(blockId, new BlockKnowledgeMemory.Entry(seen, possessed, placed));
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String field(JsonReader reader, Set<String> seen, Set<String> allowed) throws IOException {
|
||||||
|
String name = reader.nextName();
|
||||||
|
if (!allowed.contains(name) || !seen.add(name)) throw new IOException("Unknown or duplicate block knowledge field: " + name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nonnegativeLong(JsonReader reader) throws IOException {
|
||||||
|
requireToken(reader, JsonToken.NUMBER);
|
||||||
|
String value = reader.nextString();
|
||||||
|
if (!value.matches("0|[1-9][0-9]{0,18}")) throw new IOException("Block knowledge count must be a nonnegative integer");
|
||||||
|
try { return Long.parseLong(value); }
|
||||||
|
catch (NumberFormatException error) { throw new IOException("Block knowledge count exceeds the long range", error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireToken(JsonReader reader, JsonToken token) throws IOException {
|
||||||
|
if (reader.peek() != token) throw new IOException("Unexpected block knowledge JSON token; expected " + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] encode(Map<String, BlockKnowledgeMemory.Entry> entries) throws IOException {
|
||||||
|
if (entries.size() > MAX_BLOCKS) throw new IOException("Too many block knowledge entries");
|
||||||
|
JsonObject root = new JsonObject();
|
||||||
|
root.addProperty("schema", 1);
|
||||||
|
root.addProperty("player", player.toString());
|
||||||
|
JsonObject blocks = new JsonObject();
|
||||||
|
new TreeMap<>(entries).forEach((id, entry) -> {
|
||||||
|
JsonObject object = new JsonObject();
|
||||||
|
object.addProperty("seen", entry.seen());
|
||||||
|
object.addProperty("possessed", entry.possessed());
|
||||||
|
object.addProperty("placed", entry.placed());
|
||||||
|
blocks.add(id, object);
|
||||||
|
});
|
||||||
|
root.add("blocks", blocks);
|
||||||
|
byte[] bytes = (JSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length > MAX_BYTES) throw new IOException("Block knowledge exceeds its size limit");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forceDirectory() throws IOException {
|
||||||
|
// Windows does not support opening directories through FileChannel.
|
||||||
|
if ("\\".equals(directory.getFileSystem().getSeparator())) return;
|
||||||
|
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IOException storageError(String message, Exception error) {
|
||||||
|
return error instanceof IOException io ? io : new IOException(message, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.mixin.CensusChunkMapAccessor;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunk;
|
||||||
|
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||||
|
|
||||||
|
/** Loaded FULL chunks only: no tickets, blocking futures, region reads or generation. */
|
||||||
|
public final class LoadedChunks {
|
||||||
|
private LoadedChunks() { }
|
||||||
|
|
||||||
|
public static List<LevelChunk> full(ServerLevel level) {
|
||||||
|
if (!level.getServer().isSameThread()) throw new IllegalStateException("Loaded chunks must be inspected on the server thread");
|
||||||
|
var holders = ((CensusChunkMapAccessor) level.getChunkSource().chunkMap).sanctuary$censusVisibleChunks();
|
||||||
|
List<LevelChunk> chunks = new ArrayList<>();
|
||||||
|
for (var holder : holders.values()) {
|
||||||
|
// This uses the existing completed future's getNow, matching getChunkNow.
|
||||||
|
if (holder.getChunkIfPresent(ChunkStatus.FULL) instanceof LevelChunk chunk) chunks.add(chunk);
|
||||||
|
}
|
||||||
|
return List.copyOf(chunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.RandomizableContainer;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.entity.vehicle.ContainerEntity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only stock at one server tick. This is a census of accessible, already loaded objects,
|
||||||
|
* not a count of generated terrain, unopened loot, offline players or the complete saved world.
|
||||||
|
*/
|
||||||
|
public final class StockCensus {
|
||||||
|
private StockCensus() { }
|
||||||
|
|
||||||
|
public static Snapshot snapshot(MinecraftServer server) {
|
||||||
|
if (!server.isSameThread()) throw new IllegalStateException("Stock census must run on the server thread");
|
||||||
|
var players = new TreeMap<String, Long>();
|
||||||
|
var blocks = new TreeMap<String, Long>();
|
||||||
|
var containers = new TreeMap<String, Long>();
|
||||||
|
var ground = new TreeMap<String, Long>();
|
||||||
|
long dimensions = 0;
|
||||||
|
long chunks = 0;
|
||||||
|
long blockContainers = 0;
|
||||||
|
long entityContainers = 0;
|
||||||
|
long itemEntities = 0;
|
||||||
|
long pendingLoot = 0;
|
||||||
|
long pendingBlockEntities = 0;
|
||||||
|
|
||||||
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||||
|
// Inventory.getItem also covers equipment; do not count equipment a second time.
|
||||||
|
count(player.getInventory(), players);
|
||||||
|
if (player.containerMenu != null) add(player.containerMenu.getCarried(), players);
|
||||||
|
}
|
||||||
|
for (var level : server.getAllLevels()) {
|
||||||
|
dimensions++;
|
||||||
|
for (var chunk : LoadedChunks.full(level)) {
|
||||||
|
chunks++;
|
||||||
|
// Reading packed block-entity NBT through getBlockEntity would instantiate it.
|
||||||
|
// Report that omission instead of changing the loaded chunk during a census.
|
||||||
|
pendingBlockEntities += chunk.getBlockEntitiesPos().size() - chunk.getBlockEntities().size();
|
||||||
|
for (var blockEntity : chunk.getBlockEntities().values()) {
|
||||||
|
if (blockEntity.isRemoved() || !(blockEntity instanceof Container container)) continue;
|
||||||
|
if (pendingLoot(container)) {
|
||||||
|
pendingLoot++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blockContainers++;
|
||||||
|
// A chest block entity owns only its half, even when its menu is a double chest.
|
||||||
|
count(container, blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var entity : level.getAllEntities()) {
|
||||||
|
if (entity.isRemoved() || entity instanceof ServerPlayer) continue;
|
||||||
|
if (entity instanceof ItemEntity item) {
|
||||||
|
itemEntities++;
|
||||||
|
add(item.getItem(), ground);
|
||||||
|
} else if (entity instanceof Container container) {
|
||||||
|
if (pendingLoot(container)) {
|
||||||
|
pendingLoot++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entityContainers++;
|
||||||
|
count(container, containers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Snapshot(server.getTickCount(), players, blocks, containers, ground,
|
||||||
|
new Coverage(dimensions, chunks, server.getPlayerList().getPlayers().size(), blockContainers,
|
||||||
|
entityContainers, itemEntities, pendingLoot, pendingBlockEntities));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check both vanilla loot interfaces before calling any slot getter. */
|
||||||
|
private static boolean pendingLoot(Container container) {
|
||||||
|
return (container instanceof RandomizableContainer randomizable && randomizable.getLootTable() != null)
|
||||||
|
|| (container instanceof ContainerEntity entity && entity.getContainerLootTable() != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void count(Container container, Map<String, Long> result) {
|
||||||
|
for (int slot = 0; slot < container.getContainerSize(); slot++) add(container.getItem(slot), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(ItemStack stack, Map<String, Long> result) {
|
||||||
|
if (!stack.isEmpty()) result.merge(BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(),
|
||||||
|
(long) stack.getCount(), Math::addExact);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Long> immutable(Map<String, Long> items) {
|
||||||
|
return Collections.unmodifiableMap(new TreeMap<>(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All four categories use item identifiers and units, including non-block items. */
|
||||||
|
public record Snapshot(long serverTick, Map<String, Long> playerItems, Map<String, Long> blockContainerItems,
|
||||||
|
Map<String, Long> entityContainerItems, Map<String, Long> groundItems, Coverage coverage) {
|
||||||
|
public Snapshot {
|
||||||
|
playerItems = immutable(playerItems);
|
||||||
|
blockContainerItems = immutable(blockContainerItems);
|
||||||
|
entityContainerItems = immutable(entityContainerItems);
|
||||||
|
groundItems = immutable(groundItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long total(String itemId) {
|
||||||
|
return Math.addExact(Math.addExact(playerItems.getOrDefault(itemId, 0L), blockContainerItems.getOrDefault(itemId, 0L)),
|
||||||
|
Math.addExact(entityContainerItems.getOrDefault(itemId, 0L), groundItems.getOrDefault(itemId, 0L)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Container counts include only read containers; unopened loot is counted separately. */
|
||||||
|
public record Coverage(long dimensions, long loadedChunks, long onlinePlayers, long scannedBlockContainers,
|
||||||
|
long scannedEntityContainers, long groundItemEntities, long pendingLootContainers,
|
||||||
|
long pendingBlockEntities) {
|
||||||
|
public boolean partial() { return true; }
|
||||||
|
public boolean loadedOnly() { return true; }
|
||||||
|
public boolean nestedContentsIncluded() { return false; }
|
||||||
|
public boolean offlineIncluded() { return false; }
|
||||||
|
public boolean enderStorageIncluded() { return false; }
|
||||||
|
public boolean menuInputsIncluded() { return false; }
|
||||||
|
public boolean mobInventoriesIncluded() { return false; }
|
||||||
|
public boolean mobEquipmentIncluded() { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.ChunkPos;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral terrain distribution over currently loaded FULL chunks. Only up-to-date sections
|
||||||
|
* contribute counts; pending or unloaded terrain is never silently treated as known empty land.
|
||||||
|
* Air is excluded; fluids retain their native block identifiers. Nothing is written to the world.
|
||||||
|
*/
|
||||||
|
public final class TerrainCensus {
|
||||||
|
public static final int SECTION_BUDGET_PER_TICK = 16;
|
||||||
|
private static final Map<MinecraftServer, CensusState> SERVERS = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
private TerrainCensus() { }
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
ServerTickEvents.END_SERVER_TICK.register(TerrainCensus::refresh);
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPED.register(SERVERS::remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repeated reads share one server-tick palette budget. Returned maps and lists are immutable. */
|
||||||
|
public static Snapshot snapshot(MinecraftServer server) {
|
||||||
|
CensusState state = refresh(server);
|
||||||
|
long now = server.getTickCount();
|
||||||
|
List<DimensionSnapshot> dimensions = new ArrayList<>();
|
||||||
|
for (var dimension : state.dimensions.entrySet()) {
|
||||||
|
DimensionState data = dimension.getValue();
|
||||||
|
Map<String, Long> counts = new TreeMap<>();
|
||||||
|
int countedSections = 0;
|
||||||
|
long lastCountedTick = -1;
|
||||||
|
long oldestPendingTicks = 0;
|
||||||
|
for (SectionEntry entry : data.sections.values()) {
|
||||||
|
if (entry.counts == null) {
|
||||||
|
oldestPendingTicks = Math.max(oldestPendingTicks, now - entry.pendingSince);
|
||||||
|
} else {
|
||||||
|
countedSections++;
|
||||||
|
lastCountedTick = Math.max(lastCountedTick, entry.countedTick);
|
||||||
|
entry.counts.forEach((block, count) -> counts.merge(block, count, Long::sum));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int countedChunks = (int) data.chunks.values().stream().filter(chunk -> chunk.sections.stream().allMatch(section -> section.counts != null)).count();
|
||||||
|
dimensions.add(new DimensionSnapshot(dimension.getKey().dimension().identifier().toString(), counts,
|
||||||
|
data.chunks.size(), countedChunks, data.sections.size(), countedSections, lastCountedTick, oldestPendingTicks));
|
||||||
|
}
|
||||||
|
dimensions.sort(Comparator.comparing(DimensionSnapshot::dimensionId));
|
||||||
|
return new Snapshot(now, dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spatial detail for future generation planning; absence never causes a chunk to load. */
|
||||||
|
public static Optional<ChunkSnapshot> chunkSnapshot(ServerLevel level, ChunkPos pos) {
|
||||||
|
var state = refresh(level.getServer());
|
||||||
|
var dimension = state.dimensions.get(level);
|
||||||
|
if (dimension == null) return Optional.empty();
|
||||||
|
var chunk = dimension.chunks.get(pos.pack());
|
||||||
|
if (chunk == null) return Optional.empty();
|
||||||
|
var sections = chunk.sections;
|
||||||
|
long now = level.getServer().getTickCount();
|
||||||
|
Map<String, Long> counts = new TreeMap<>();
|
||||||
|
int countedSections = 0;
|
||||||
|
long lastCountedTick = -1;
|
||||||
|
long oldestPendingTicks = 0;
|
||||||
|
for (SectionEntry entry : sections) {
|
||||||
|
if (entry.counts == null) {
|
||||||
|
oldestPendingTicks = Math.max(oldestPendingTicks, now - entry.pendingSince);
|
||||||
|
} else {
|
||||||
|
countedSections++;
|
||||||
|
lastCountedTick = Math.max(lastCountedTick, entry.countedTick);
|
||||||
|
entry.counts.forEach((block, count) -> counts.merge(block, count, Long::sum));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.of(new ChunkSnapshot(level.dimension().identifier().toString(), pos.x(), pos.z(), counts,
|
||||||
|
sections.size(), countedSections, now, lastCountedTick, oldestPendingTicks));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CensusState refresh(MinecraftServer server) {
|
||||||
|
if (!server.isSameThread()) throw new IllegalStateException("Terrain census must run on the server thread");
|
||||||
|
CensusState state = SERVERS.computeIfAbsent(server, ignored -> new CensusState());
|
||||||
|
long now = server.getTickCount();
|
||||||
|
long epoch = ++state.epoch;
|
||||||
|
var activeLevels = Collections.newSetFromMap(new IdentityHashMap<ServerLevel, Boolean>());
|
||||||
|
for (ServerLevel level : server.getAllLevels()) {
|
||||||
|
activeLevels.add(level);
|
||||||
|
DimensionState dimension = state.dimensions.computeIfAbsent(level, ignored -> new DimensionState());
|
||||||
|
for (var chunk : LoadedChunks.full(level)) {
|
||||||
|
long key = chunk.getPos().pack();
|
||||||
|
var nativeSections = chunk.getSections();
|
||||||
|
ChunkEntry chunkEntry = dimension.chunks.get(key);
|
||||||
|
if (!sameSections(chunkEntry, nativeSections)) {
|
||||||
|
List<SectionEntry> sections = new ArrayList<>(nativeSections.length);
|
||||||
|
for (var section : nativeSections) {
|
||||||
|
SectionEntry entry = dimension.sections.get(section);
|
||||||
|
if (entry == null) {
|
||||||
|
entry = new SectionEntry(section, now);
|
||||||
|
dimension.sections.put(section, entry);
|
||||||
|
}
|
||||||
|
sections.add(entry);
|
||||||
|
}
|
||||||
|
chunkEntry = new ChunkEntry(List.copyOf(sections));
|
||||||
|
dimension.chunks.put(key, chunkEntry);
|
||||||
|
}
|
||||||
|
chunkEntry.seenEpoch = epoch;
|
||||||
|
for (SectionEntry entry : chunkEntry.sections) {
|
||||||
|
var section = entry.section;
|
||||||
|
entry.seenEpoch = epoch;
|
||||||
|
long revision = revision(section);
|
||||||
|
if (entry.counts != null && entry.countedRevision != revision) {
|
||||||
|
entry.counts = null;
|
||||||
|
entry.pendingSince = now;
|
||||||
|
}
|
||||||
|
if (entry.counts == null) {
|
||||||
|
if (section.hasOnlyAir()) {
|
||||||
|
// Native section metadata proves this in O(1), without a palette scan.
|
||||||
|
entry.counts = Map.of();
|
||||||
|
entry.countedRevision = revision;
|
||||||
|
entry.countedTick = now;
|
||||||
|
if (entry.queued) {
|
||||||
|
state.pending.remove(entry);
|
||||||
|
entry.queued = false;
|
||||||
|
}
|
||||||
|
} else if (!entry.queued) {
|
||||||
|
entry.queued = true;
|
||||||
|
state.pending.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dimension.chunks.values().removeIf(chunk -> chunk.seenEpoch != epoch);
|
||||||
|
dimension.sections.values().removeIf(entry -> {
|
||||||
|
if (entry.seenEpoch == epoch) return false;
|
||||||
|
entry.active = false;
|
||||||
|
state.pending.remove(entry);
|
||||||
|
entry.queued = false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.dimensions.entrySet().removeIf(entry -> {
|
||||||
|
if (activeLevels.contains(entry.getKey())) return false;
|
||||||
|
entry.getValue().sections.values().forEach(section -> {
|
||||||
|
section.active = false;
|
||||||
|
state.pending.remove(section);
|
||||||
|
section.queued = false;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (state.budgetTick != now) {
|
||||||
|
state.budgetTick = now;
|
||||||
|
state.spent = 0;
|
||||||
|
}
|
||||||
|
while (state.spent < SECTION_BUDGET_PER_TICK && !state.pending.isEmpty()) {
|
||||||
|
var iterator = state.pending.iterator();
|
||||||
|
SectionEntry entry = iterator.next();
|
||||||
|
iterator.remove();
|
||||||
|
entry.queued = false;
|
||||||
|
if (!entry.active || entry.counts != null) continue;
|
||||||
|
state.spent++;
|
||||||
|
long before = revision(entry.section);
|
||||||
|
Map<String, Long> counts = new HashMap<>();
|
||||||
|
entry.section.getStates().count((blockState, count) -> {
|
||||||
|
if (!blockState.isAir()) counts.merge(BuiltInRegistries.BLOCK.getKey(blockState.getBlock()).toString(), (long) count, Long::sum);
|
||||||
|
});
|
||||||
|
if (before != revision(entry.section)) {
|
||||||
|
entry.queued = true;
|
||||||
|
state.pending.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.counts = Map.copyOf(counts);
|
||||||
|
entry.countedRevision = before;
|
||||||
|
entry.countedTick = now;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long revision(LevelChunkSection section) {
|
||||||
|
return ((TerrainSectionRevision) section).sanctuary$censusRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean sameSections(ChunkEntry known, LevelChunkSection[] sections) {
|
||||||
|
if (known == null || known.sections.size() != sections.length) return false;
|
||||||
|
for (int index = 0; index < sections.length; index++) {
|
||||||
|
if (known.sections.get(index).section != sections[index]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Snapshot(long capturedTick, List<DimensionSnapshot> dimensions) {
|
||||||
|
public Snapshot { dimensions = List.copyOf(dimensions); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DimensionSnapshot(String dimensionId, Map<String, Long> blockCounts, int loadedChunks,
|
||||||
|
int countedChunks, int loadedSections, int countedSections, long lastCountedTick, long oldestPendingTicks) {
|
||||||
|
public DimensionSnapshot {
|
||||||
|
blockCounts = Collections.unmodifiableMap(new TreeMap<>(blockCounts));
|
||||||
|
}
|
||||||
|
public boolean complete() { return countedSections == loadedSections; }
|
||||||
|
public double coverage() { return loadedSections == 0 ? 1.0 : (double) countedSections / loadedSections; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChunkSnapshot(String dimensionId, int chunkX, int chunkZ, Map<String, Long> blockCounts,
|
||||||
|
int loadedSections, int countedSections, long capturedTick, long lastCountedTick, long oldestPendingTicks) {
|
||||||
|
public ChunkSnapshot { blockCounts = Collections.unmodifiableMap(new TreeMap<>(blockCounts)); }
|
||||||
|
public boolean complete() { return countedSections == loadedSections; }
|
||||||
|
public double coverage() { return loadedSections == 0 ? 1.0 : (double) countedSections / loadedSections; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CensusState {
|
||||||
|
final Map<ServerLevel, DimensionState> dimensions = new IdentityHashMap<>();
|
||||||
|
final LinkedHashSet<SectionEntry> pending = new LinkedHashSet<>();
|
||||||
|
long epoch;
|
||||||
|
long budgetTick = Long.MIN_VALUE;
|
||||||
|
int spent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class DimensionState {
|
||||||
|
final Map<LevelChunkSection, SectionEntry> sections = new IdentityHashMap<>();
|
||||||
|
final Map<Long, ChunkEntry> chunks = new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ChunkEntry {
|
||||||
|
final List<SectionEntry> sections;
|
||||||
|
long seenEpoch;
|
||||||
|
ChunkEntry(List<SectionEntry> sections) { this.sections = sections; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SectionEntry {
|
||||||
|
final LevelChunkSection section;
|
||||||
|
Map<String, Long> counts;
|
||||||
|
long countedRevision = -1;
|
||||||
|
long countedTick = -1;
|
||||||
|
long pendingSince;
|
||||||
|
long seenEpoch;
|
||||||
|
boolean active = true;
|
||||||
|
boolean queued;
|
||||||
|
SectionEntry(LevelChunkSection section, long now) { this.section = section; pendingSince = now; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
/** Revision of material counts, independent of block-state properties such as facing. */
|
||||||
|
public interface TerrainSectionRevision {
|
||||||
|
long sanctuary$censusRevision();
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
import net.minecraft.commands.arguments.IdentifierArgument;
|
||||||
|
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.commands.SharedSuggestionProvider;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/** Operator diagnostics. Other players' aggregated inventories are not sent to the personal UI. */
|
||||||
|
public final class WorldCensusCommands {
|
||||||
|
private static final String TEXT = "sanctuary.census.";
|
||||||
|
private WorldCensusCommands() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) ->
|
||||||
|
dispatcher.register(Commands.literal("sanctuary")
|
||||||
|
.requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS))
|
||||||
|
.then(Commands.literal("census").executes(context -> summary(context.getSource()))
|
||||||
|
.then(Commands.literal("block").then(Commands.argument("id", IdentifierArgument.id())
|
||||||
|
.suggests((context, builder) -> SharedSuggestionProvider.suggest(
|
||||||
|
BuiltInRegistries.BLOCK.keySet().stream().map(Identifier::toString), builder))
|
||||||
|
.executes(context -> block(context.getSource(), IdentifierArgument.getId(context, "id").toString()))))
|
||||||
|
.then(Commands.literal("item").then(Commands.argument("id", IdentifierArgument.id())
|
||||||
|
.suggests((context, builder) -> SharedSuggestionProvider.suggest(
|
||||||
|
BuiltInRegistries.ITEM.keySet().stream().map(Identifier::toString), builder))
|
||||||
|
.executes(context -> item(context.getSource(), IdentifierArgument.getId(context, "id").toString())))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int summary(CommandSourceStack source) {
|
||||||
|
var snapshot = WorldCensusService.capture(source.getServer());
|
||||||
|
send(source, "scope", snapshot.terrain().capturedTick());
|
||||||
|
for (var dimension : snapshot.terrain().dimensions()) {
|
||||||
|
send(source, "terrain", dimension.dimensionId(), dimension.countedChunks(), dimension.loadedChunks(),
|
||||||
|
dimension.countedSections(), dimension.loadedSections(), dimension.oldestPendingTicks());
|
||||||
|
}
|
||||||
|
var stocks = snapshot.stocks();
|
||||||
|
send(source, "stocks", total(stocks.playerItems()), total(stocks.blockContainerItems()),
|
||||||
|
total(stocks.entityContainerItems()), total(stocks.groundItems()));
|
||||||
|
send(source, "pending_loot", stocks.coverage().pendingLootContainers());
|
||||||
|
send(source, "help");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int block(CommandSourceStack source, String value) {
|
||||||
|
Identifier id = Identifier.tryParse(value);
|
||||||
|
if (id == null || !BuiltInRegistries.BLOCK.containsKey(id)) {
|
||||||
|
source.sendFailure(Component.translatable(TEXT + "unknown_block", value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var snapshot = TerrainCensus.snapshot(source.getServer());
|
||||||
|
send(source, "scope", snapshot.capturedTick());
|
||||||
|
for (var dimension : snapshot.dimensions()) {
|
||||||
|
send(source, "block", id.toString(), dimension.dimensionId(), dimension.blockCounts().getOrDefault(id.toString(), 0L),
|
||||||
|
dimension.countedSections(), dimension.loadedSections());
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int item(CommandSourceStack source, String value) {
|
||||||
|
Identifier id = Identifier.tryParse(value);
|
||||||
|
if (id == null || !BuiltInRegistries.ITEM.containsKey(id)) {
|
||||||
|
source.sendFailure(Component.translatable(TEXT + "unknown_item", value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var stocks = StockCensus.snapshot(source.getServer());
|
||||||
|
String key = id.toString();
|
||||||
|
send(source, "scope", stocks.serverTick());
|
||||||
|
send(source, "item", key, stocks.playerItems().getOrDefault(key, 0L), stocks.blockContainerItems().getOrDefault(key, 0L),
|
||||||
|
stocks.entityContainerItems().getOrDefault(key, 0L), stocks.groundItems().getOrDefault(key, 0L));
|
||||||
|
send(source, "pending_loot", stocks.coverage().pendingLootContainers());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long total(java.util.Map<String, Long> counts) {
|
||||||
|
long total = 0;
|
||||||
|
for (long count : counts.values()) total = Long.MAX_VALUE - total < count ? Long.MAX_VALUE : total + count;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void send(CommandSourceStack source, String key, Object... arguments) {
|
||||||
|
source.sendSuccess(() -> Component.translatable(TEXT + key, arguments), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.census;
|
||||||
|
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
|
||||||
|
/** Shared observations for future expansion and shop rules, with explicit loaded-world coverage. */
|
||||||
|
public final class WorldCensusService {
|
||||||
|
private WorldCensusService() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
TerrainCensus.register();
|
||||||
|
WorldCensusCommands.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture once for a decision, then reuse the immutable result across its calculations. */
|
||||||
|
public static Snapshot capture(MinecraftServer server) {
|
||||||
|
if (!server.isSameThread()) throw new IllegalStateException("World census must be read on the server thread");
|
||||||
|
return new Snapshot(TerrainCensus.snapshot(server), StockCensus.snapshot(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Snapshot(TerrainCensus.Snapshot terrain, StockCensus.Snapshot stocks) {
|
||||||
|
public long terrainCount(String blockId) {
|
||||||
|
long total = 0;
|
||||||
|
for (var dimension : terrain.dimensions()) total = add(total, dimension.blockCounts().getOrDefault(blockId, 0L));
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long stockCount(String itemId) {
|
||||||
|
long total = stocks.playerItems().getOrDefault(itemId, 0L);
|
||||||
|
total = add(total, stocks.blockContainerItems().getOrDefault(itemId, 0L));
|
||||||
|
total = add(total, stocks.entityContainerItems().getOrDefault(itemId, 0L));
|
||||||
|
return add(total, stocks.groundItems().getOrDefault(itemId, 0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long add(long left, long right) { return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; }
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.history;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** A stable material/action identity, independent of current registry availability. */
|
||||||
|
public record MaterialActivity(String action, String registry, String materialId)
|
||||||
|
implements Comparable<MaterialActivity> {
|
||||||
|
private static final Pattern ACTION = Pattern.compile("[a-z][a-z0-9_]{0,63}");
|
||||||
|
private static final Pattern MATERIAL = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||||
|
|
||||||
|
public MaterialActivity {
|
||||||
|
if (action == null || !ACTION.matcher(action).matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid material activity action");
|
||||||
|
}
|
||||||
|
if (!"block".equals(registry) && !"item".equals(registry)) {
|
||||||
|
throw new IllegalArgumentException("Material activity registry must be block or item");
|
||||||
|
}
|
||||||
|
if (materialId == null || !MATERIAL.matcher(materialId).matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid namespaced material identifier");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(MaterialActivity other) {
|
||||||
|
Objects.requireNonNull(other, "other");
|
||||||
|
int result = action.compareTo(other.action);
|
||||||
|
if (result == 0) result = registry.compareTo(other.registry);
|
||||||
|
if (result == 0) result = materialId.compareTo(other.materialId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.history;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
/** Read-only operator access to UTC daily observations. */
|
||||||
|
public final class MaterialActivityCommands {
|
||||||
|
private static final String TEXT = "sanctuary.history.";
|
||||||
|
private MaterialActivityCommands() { }
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
CommandRegistrationCallback.EVENT.register((dispatcher, registries, environment) ->
|
||||||
|
dispatcher.register(Commands.literal("sanctuary")
|
||||||
|
.requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS))
|
||||||
|
.then(Commands.literal("history")
|
||||||
|
.executes(context -> show(context.getSource(), LocalDate.now(ZoneOffset.UTC).toString()))
|
||||||
|
.then(Commands.argument("date", StringArgumentType.word()).executes(context ->
|
||||||
|
show(context.getSource(), StringArgumentType.getString(context, "date")))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int show(CommandSourceStack source, String value) {
|
||||||
|
LocalDate day;
|
||||||
|
try {
|
||||||
|
if (!value.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}")) throw new DateTimeParseException("Expected YYYY-MM-DD", value, 0);
|
||||||
|
day = LocalDate.parse(value);
|
||||||
|
if (day.getYear() < 1) throw new DateTimeParseException("Expected a positive year", value, 0);
|
||||||
|
} catch (DateTimeParseException error) {
|
||||||
|
source.sendFailure(Component.translatable(TEXT + "date_invalid", value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var snapshot = MaterialActivityHistory.snapshot(source.getServer(), day);
|
||||||
|
send(source, "scope");
|
||||||
|
if (!snapshot.recorded()) {
|
||||||
|
send(source, "missing", day.toString());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
send(source, "summary", day.toString(), snapshot.counts().size(), snapshot.unsaved());
|
||||||
|
snapshot.counts().entrySet().stream().sorted(java.util.Map.Entry.<MaterialActivity, Long>comparingByValue().reversed()
|
||||||
|
.thenComparing(java.util.Map.Entry.comparingByKey())).limit(16).forEach(entry -> {
|
||||||
|
MaterialActivity activity = entry.getKey();
|
||||||
|
send(source, "row", Component.translatable(TEXT + "action." + activity.action()),
|
||||||
|
activity.registry(), activity.materialId(), entry.getValue());
|
||||||
|
});
|
||||||
|
if (snapshot.counts().size() > 16) send(source, "limited", 16, snapshot.counts().size());
|
||||||
|
return 1;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
source.sendFailure(Component.translatable(TEXT + "error", error.getMessage()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void send(CommandSourceStack source, String key, Object... arguments) {
|
||||||
|
source.sendSuccess(() -> Component.translatable(TEXT + key, arguments), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.history;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.SanctuaryMod;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.stats.Stat;
|
||||||
|
import net.minecraft.stats.Stats;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
|
||||||
|
/** Dated server-wide activity aggregates, not stock, consumption or a transaction ledger. */
|
||||||
|
public final class MaterialActivityHistory {
|
||||||
|
public static final int SAVE_INTERVAL = 600;
|
||||||
|
private static final Map<MinecraftServer, Session> SERVERS = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
private MaterialActivityHistory() { }
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
ServerTickEvents.END_SERVER_TICK.register(server -> {
|
||||||
|
if (server.getTickCount() % SAVE_INTERVAL == 0) flush(server);
|
||||||
|
});
|
||||||
|
ServerLifecycleEvents.BEFORE_SAVE.register((server, flush, force) -> flush(server));
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPING.register(MaterialActivityHistory::flush);
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPED.register(SERVERS::remove);
|
||||||
|
MaterialActivityCommands.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native awardStat has finished. The increment is observed once, without replaying old totals. */
|
||||||
|
public static void recordStat(ServerPlayer player, Stat<?> stat, int count) {
|
||||||
|
if (count <= 0) return;
|
||||||
|
if (stat.getType() == Stats.BLOCK_MINED && stat.getValue() instanceof Block block) {
|
||||||
|
record(player.level().getServer(), new MaterialActivity("mined", "block",
|
||||||
|
BuiltInRegistries.BLOCK.getKey(block).toString()), count);
|
||||||
|
} else if (stat.getValue() instanceof Item item) {
|
||||||
|
String action;
|
||||||
|
if (stat.getType() == Stats.ITEM_PICKED_UP) action = "picked_up";
|
||||||
|
else if (stat.getType() == Stats.ITEM_DROPPED) action = "dropped";
|
||||||
|
else if (stat.getType() == Stats.ITEM_CRAFTED) action = "crafted";
|
||||||
|
else return;
|
||||||
|
record(player.level().getServer(), new MaterialActivity(action, "item",
|
||||||
|
BuiltInRegistries.ITEM.getKey(item).toString()), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The confirmed BlockItem placement state, including wall variants; not inferred from item use. */
|
||||||
|
public static void recordPlaced(ServerPlayer player, Block block) {
|
||||||
|
if (player.isSpectator() || block.defaultBlockState().isAir()) return;
|
||||||
|
record(player.level().getServer(), new MaterialActivity("placed", "block",
|
||||||
|
BuiltInRegistries.BLOCK.getKey(block).toString()), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void record(MinecraftServer server, MaterialActivity activity, long count) {
|
||||||
|
Session session = current(server);
|
||||||
|
if (session.failed) return;
|
||||||
|
session.counts.merge(activity, count,
|
||||||
|
(before, increment) -> Long.MAX_VALUE - before < increment ? Long.MAX_VALUE : before + increment);
|
||||||
|
session.dirty = true;
|
||||||
|
session.recorded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Missing days are explicitly unrecorded; they are not evidence of no activity. */
|
||||||
|
public static Snapshot snapshot(MinecraftServer server, LocalDate day) throws IOException {
|
||||||
|
assertServerThread(server);
|
||||||
|
Session active = SERVERS.get(server);
|
||||||
|
// A failed session stays frozen across UTC midnight. Do not disguise that
|
||||||
|
// outage as an unrecorded new day when looking up a different date.
|
||||||
|
if (active != null && active.failed) {
|
||||||
|
throw new IOException("Material activity history is unavailable; inspect the server log");
|
||||||
|
}
|
||||||
|
if (active != null && day.equals(active.day)) {
|
||||||
|
return new Snapshot(day, active.counts, active.recorded, active.dirty);
|
||||||
|
}
|
||||||
|
MaterialActivityStore store = new MaterialActivityStore(directory(server), day);
|
||||||
|
Map<MaterialActivity, Long> counts = store.load();
|
||||||
|
return new Snapshot(day, counts, Files.exists(store.path()), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void flush(MinecraftServer server) {
|
||||||
|
assertServerThread(server);
|
||||||
|
Session session = SERVERS.get(server);
|
||||||
|
if (session == null || session.failed || !session.dirty) return;
|
||||||
|
try {
|
||||||
|
session.store.save(session.counts);
|
||||||
|
session.dirty = false;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
fail(session, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Session current(MinecraftServer server) {
|
||||||
|
assertServerThread(server);
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Session session = SERVERS.get(server);
|
||||||
|
if (session != null && (session.failed || today.equals(session.day))) return session;
|
||||||
|
if (session != null) {
|
||||||
|
flush(server);
|
||||||
|
if (session.failed) return session;
|
||||||
|
}
|
||||||
|
session = new Session(today, new MaterialActivityStore(directory(server), today));
|
||||||
|
SERVERS.put(server, session);
|
||||||
|
try {
|
||||||
|
session.counts.putAll(session.store.load());
|
||||||
|
session.recorded = Files.exists(session.store.path());
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
fail(session, error);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path directory(MinecraftServer server) {
|
||||||
|
return server.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertServerThread(MinecraftServer server) {
|
||||||
|
if (!server.isSameThread()) throw new IllegalStateException("Material activity must run on the server thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fail(Session session, Exception error) {
|
||||||
|
session.failed = true;
|
||||||
|
SanctuaryMod.LOGGER.error("Material activity recording stopped; preserving {}. Restart after repairing the reported failure.",
|
||||||
|
session.store.path(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Snapshot(LocalDate day, Map<MaterialActivity, Long> counts, boolean recorded, boolean unsaved) {
|
||||||
|
public Snapshot { counts = Collections.unmodifiableMap(new TreeMap<>(counts)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Session {
|
||||||
|
final LocalDate day;
|
||||||
|
final MaterialActivityStore store;
|
||||||
|
final Map<MaterialActivity, Long> counts = new TreeMap<>();
|
||||||
|
boolean recorded;
|
||||||
|
boolean dirty;
|
||||||
|
boolean failed;
|
||||||
|
Session(LocalDate day, MaterialActivityStore store) { this.day = day; this.store = store; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.history;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.Strictness;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonToken;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/** Daily UTC activity totals, without inferring stocks, transactions or ballast. */
|
||||||
|
public final class MaterialActivityStore {
|
||||||
|
private static final int MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
private static final int MAX_ENTRIES = 100_000;
|
||||||
|
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
private static final Set<String> ROOT_FIELDS = Set.of("schema", "date", "entries");
|
||||||
|
private static final Set<String> ENTRY_FIELDS = Set.of("action", "registry", "material", "count");
|
||||||
|
private final Path directory;
|
||||||
|
private final Path file;
|
||||||
|
private final LocalDate day;
|
||||||
|
private boolean loaded;
|
||||||
|
private boolean failed;
|
||||||
|
private byte[] previousBytes;
|
||||||
|
private Map<MaterialActivity, Long> previousEntries;
|
||||||
|
|
||||||
|
public MaterialActivityStore(Path directory, LocalDate day) {
|
||||||
|
this.directory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize();
|
||||||
|
this.day = Objects.requireNonNull(day, "day");
|
||||||
|
this.file = this.directory.resolve(day + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path path() { return file; }
|
||||||
|
|
||||||
|
/** No file is created until the first changed, nonempty snapshot is saved. */
|
||||||
|
public synchronized Map<MaterialActivity, Long> load() throws IOException {
|
||||||
|
if (failed) throw new IOException("Material activity storage failed earlier; inspect it before reloading");
|
||||||
|
try {
|
||||||
|
checkDirectory();
|
||||||
|
byte[] bytes = readBytes();
|
||||||
|
Map<MaterialActivity, Long> entries = bytes == null ? Collections.emptySortedMap() : decode(bytes);
|
||||||
|
previousBytes = bytes;
|
||||||
|
previousEntries = entries;
|
||||||
|
loaded = true;
|
||||||
|
return entries;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
failed = true;
|
||||||
|
throw storageError("Could not load material activity", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void save(Map<MaterialActivity, Long> proposed) throws IOException {
|
||||||
|
if (failed || !loaded) throw new IOException("Material activity must load successfully before saving");
|
||||||
|
Path temporary = null;
|
||||||
|
try {
|
||||||
|
Map<MaterialActivity, Long> entries = snapshot(proposed);
|
||||||
|
checkDirectory();
|
||||||
|
if (!Arrays.equals(previousBytes, readBytes())) {
|
||||||
|
throw new IOException("Material activity changed outside this store; refusing to replace it");
|
||||||
|
}
|
||||||
|
if (previousEntries.equals(entries)) return;
|
||||||
|
byte[] bytes = encode(entries);
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
temporary = Files.createTempFile(directory, "." + day + "-", ".tmp");
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||||
|
while (buffer.hasRemaining()) channel.write(buffer);
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
temporary = null;
|
||||||
|
forceDirectory();
|
||||||
|
byte[] verified = readBytes();
|
||||||
|
if (!Arrays.equals(bytes, verified) || !decode(verified).equals(entries)) {
|
||||||
|
throw new IOException("Material activity read-back differs from its saved snapshot");
|
||||||
|
}
|
||||||
|
previousBytes = verified;
|
||||||
|
previousEntries = entries;
|
||||||
|
} catch (IOException | RuntimeException error) {
|
||||||
|
failed = true;
|
||||||
|
throw storageError("Could not save material activity", error);
|
||||||
|
} finally {
|
||||||
|
if (temporary != null) {
|
||||||
|
try { Files.deleteIfExists(temporary); } catch (IOException ignored) { /* Preserve the original failure. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkDirectory() throws IOException {
|
||||||
|
if (!Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||||
|
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("Material activity path is not a regular directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] readBytes() throws IOException {
|
||||||
|
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return null;
|
||||||
|
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Material activity is not a regular file");
|
||||||
|
if (Files.size(file) > MAX_BYTES) throw new IOException("Material activity exceeds its size limit");
|
||||||
|
try (var input = Files.newInputStream(file)) {
|
||||||
|
byte[] bytes = input.readNBytes(MAX_BYTES + 1);
|
||||||
|
if (bytes.length > MAX_BYTES) throw new IOException("Material activity exceeds its size limit");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<MaterialActivity, Long> decode(byte[] bytes) throws IOException {
|
||||||
|
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
try (JsonReader reader = new JsonReader(new StringReader(text))) {
|
||||||
|
reader.setStrictness(Strictness.STRICT);
|
||||||
|
requireToken(reader, JsonToken.BEGIN_OBJECT);
|
||||||
|
reader.beginObject();
|
||||||
|
Set<String> fields = new HashSet<>();
|
||||||
|
Map<MaterialActivity, Long> entries = null;
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
switch (field(reader, fields, ROOT_FIELDS)) {
|
||||||
|
case "schema" -> {
|
||||||
|
if (positiveLong(reader) != 1) throw new IOException("Unsupported material activity schema");
|
||||||
|
}
|
||||||
|
case "date" -> {
|
||||||
|
if (!string(reader).equals(day.toString())) throw new IOException("Material activity date differs from its file");
|
||||||
|
}
|
||||||
|
case "entries" -> entries = readEntries(reader);
|
||||||
|
default -> throw new IOException("Unknown material activity field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
if (!fields.equals(ROOT_FIELDS)) throw new IOException("Missing material activity fields");
|
||||||
|
requireToken(reader, JsonToken.END_DOCUMENT);
|
||||||
|
return entries;
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw new IOException("Invalid material activity JSON", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<MaterialActivity, Long> readEntries(JsonReader reader) throws IOException {
|
||||||
|
requireToken(reader, JsonToken.BEGIN_ARRAY);
|
||||||
|
reader.beginArray();
|
||||||
|
Map<MaterialActivity, Long> entries = new TreeMap<>();
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
if (entries.size() >= MAX_ENTRIES) throw new IOException("Too many material activity entries");
|
||||||
|
requireToken(reader, JsonToken.BEGIN_OBJECT);
|
||||||
|
reader.beginObject();
|
||||||
|
Set<String> fields = new HashSet<>();
|
||||||
|
String action = null;
|
||||||
|
String registry = null;
|
||||||
|
String material = null;
|
||||||
|
long count = 0;
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
switch (field(reader, fields, ENTRY_FIELDS)) {
|
||||||
|
case "action" -> action = string(reader);
|
||||||
|
case "registry" -> registry = string(reader);
|
||||||
|
case "material" -> material = string(reader);
|
||||||
|
case "count" -> count = positiveLong(reader);
|
||||||
|
default -> throw new IOException("Unknown material activity entry field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject();
|
||||||
|
if (!fields.equals(ENTRY_FIELDS)) throw new IOException("Missing material activity entry fields");
|
||||||
|
MaterialActivity activity = new MaterialActivity(action, registry, material);
|
||||||
|
if (entries.putIfAbsent(activity, count) != null) throw new IOException("Duplicate material activity tuple");
|
||||||
|
}
|
||||||
|
reader.endArray();
|
||||||
|
return Collections.unmodifiableMap(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<MaterialActivity, Long> snapshot(Map<MaterialActivity, Long> proposed) throws IOException {
|
||||||
|
Objects.requireNonNull(proposed, "proposed");
|
||||||
|
if (proposed.size() > MAX_ENTRIES) throw new IOException("Too many material activity entries");
|
||||||
|
Map<MaterialActivity, Long> sorted = new TreeMap<>();
|
||||||
|
proposed.forEach((activity, count) -> {
|
||||||
|
Objects.requireNonNull(activity, "activity");
|
||||||
|
if (count == null || count < 1) throw new IllegalArgumentException("Material activity count must be positive");
|
||||||
|
sorted.put(activity, count);
|
||||||
|
});
|
||||||
|
return Collections.unmodifiableMap(sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] encode(Map<MaterialActivity, Long> entries) throws IOException {
|
||||||
|
JsonObject root = new JsonObject();
|
||||||
|
root.addProperty("schema", 1);
|
||||||
|
root.addProperty("date", day.toString());
|
||||||
|
JsonArray array = new JsonArray();
|
||||||
|
entries.forEach((activity, count) -> {
|
||||||
|
JsonObject entry = new JsonObject();
|
||||||
|
entry.addProperty("action", activity.action());
|
||||||
|
entry.addProperty("registry", activity.registry());
|
||||||
|
entry.addProperty("material", activity.materialId());
|
||||||
|
entry.addProperty("count", count);
|
||||||
|
array.add(entry);
|
||||||
|
});
|
||||||
|
root.add("entries", array);
|
||||||
|
byte[] bytes = (JSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length > MAX_BYTES) throw new IOException("Material activity exceeds its size limit");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String field(JsonReader reader, Set<String> seen, Set<String> allowed) throws IOException {
|
||||||
|
String name = reader.nextName();
|
||||||
|
if (!allowed.contains(name) || !seen.add(name)) throw new IOException("Unknown or duplicate material activity field: " + name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(JsonReader reader) throws IOException {
|
||||||
|
requireToken(reader, JsonToken.STRING);
|
||||||
|
return reader.nextString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long positiveLong(JsonReader reader) throws IOException {
|
||||||
|
requireToken(reader, JsonToken.NUMBER);
|
||||||
|
String value = reader.nextString();
|
||||||
|
if (!value.matches("[1-9][0-9]{0,18}")) throw new IOException("Material activity count must be a positive integer");
|
||||||
|
try { return Long.parseLong(value); }
|
||||||
|
catch (NumberFormatException error) { throw new IOException("Material activity count exceeds the long range", error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireToken(JsonReader reader, JsonToken token) throws IOException {
|
||||||
|
if (reader.peek() != token) throw new IOException("Unexpected material activity JSON token; expected " + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forceDirectory() throws IOException {
|
||||||
|
if ("\\".equals(directory.getFileSystem().getSeparator())) return;
|
||||||
|
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IOException storageError(String message, Exception error) {
|
||||||
|
return error instanceof IOException io ? io : new IOException(message, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package fr.koka.sanctuary.mixin;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||||
|
import net.minecraft.world.inventory.ContainerInput;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/** Retain brief possession on the cursor between two clicks in the same tick. */
|
||||||
|
@Mixin(AbstractContainerMenu.class)
|
||||||
|
public abstract class BlockKnowledgeContainerMixin {
|
||||||
|
@Inject(method = "clicked", at = @At("TAIL"))
|
||||||
|
private void sanctuary$observePlayerContents(int slot, int button, ContainerInput input,
|
||||||
|
Player player, CallbackInfo ci) {
|
||||||
|
if (player instanceof ServerPlayer serverPlayer) {
|
||||||
|
BlockKnowledgeService.observeInventory(serverPlayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package fr.koka.sanctuary.mixin;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||||
|
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.item.BlockItem;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
|
||||||
|
/** Count the confirmed block state, including wall variants and merged slabs. */
|
||||||
|
@Mixin(BlockItem.class)
|
||||||
|
public abstract class BlockKnowledgePlacementMixin {
|
||||||
|
@WrapOperation(method = "place", at = @At(value = "INVOKE", target =
|
||||||
|
"Lnet/minecraft/world/level/block/Block;setPlacedBy(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;)V"))
|
||||||
|
private void sanctuary$recordConfirmedPlacement(Block block, Level level, BlockPos pos,
|
||||||
|
BlockState state, LivingEntity placer, ItemStack stack, Operation<Void> original) {
|
||||||
|
original.call(block, level, pos, state, placer, stack);
|
||||||
|
if (placer instanceof ServerPlayer player) {
|
||||||
|
fr.koka.sanctuary.knowledge.history.MaterialActivityHistory.recordPlaced(player, state.getBlock());
|
||||||
|
// Preserve the confirmed state even when setPlacedBy immediately creates a golem.
|
||||||
|
// This runs before BlockItem consumes the held item.
|
||||||
|
BlockKnowledgeService.recordPlaced(player, state.getBlock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package fr.koka.sanctuary.mixin;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.stats.Stat;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/** Observe vanilla's authoritative counters without maintaining a second copy. */
|
||||||
|
@Mixin(ServerPlayer.class)
|
||||||
|
public abstract class BlockKnowledgeStatsMixin {
|
||||||
|
@Inject(method = "awardStat(Lnet/minecraft/stats/Stat;I)V", at = @At("TAIL"))
|
||||||
|
private void sanctuary$observeStat(Stat<?> stat, int count, CallbackInfo ci) {
|
||||||
|
fr.koka.sanctuary.knowledge.history.MaterialActivityHistory.recordStat((ServerPlayer) (Object) this, stat, count);
|
||||||
|
BlockKnowledgeService.statChanged((ServerPlayer) (Object) this, stat);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package fr.koka.sanctuary.mixin;
|
||||||
|
|
||||||
|
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||||
|
import net.minecraft.server.level.ChunkHolder;
|
||||||
|
import net.minecraft.server.level.ChunkMap;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||||
|
|
||||||
|
/** Read the already visible holders; census consumers must never add chunk tickets. */
|
||||||
|
@Mixin(ChunkMap.class)
|
||||||
|
public interface CensusChunkMapAccessor {
|
||||||
|
@Accessor("visibleChunkMap")
|
||||||
|
Long2ObjectLinkedOpenHashMap<ChunkHolder> sanctuary$censusVisibleChunks();
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package fr.koka.sanctuary.mixin;
|
||||||
|
|
||||||
|
import fr.koka.sanctuary.knowledge.census.TerrainSectionRevision;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Unique;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** Covers ordinary placement, breaking, fluids, pistons and native bulk recounts. */
|
||||||
|
@Mixin(LevelChunkSection.class)
|
||||||
|
public abstract class TerrainCensusSectionMixin implements TerrainSectionRevision {
|
||||||
|
@Unique private volatile long sanctuary$censusRevision;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long sanctuary$censusRevision() { return sanctuary$censusRevision; }
|
||||||
|
|
||||||
|
@Inject(method = "setBlockState(IIILnet/minecraft/world/level/block/state/BlockState;Z)Lnet/minecraft/world/level/block/state/BlockState;", at = @At("RETURN"))
|
||||||
|
private void sanctuary$materialChanged(int x, int y, int z, BlockState state, boolean checkThreading,
|
||||||
|
CallbackInfoReturnable<BlockState> ci) {
|
||||||
|
if (ci.getReturnValue().getBlock() != state.getBlock()) sanctuary$censusRevision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "recalcBlockCounts", at = @At("RETURN"))
|
||||||
|
private void sanctuary$bulkMaterialsChanged(CallbackInfo ci) { sanctuary$censusRevision++; }
|
||||||
|
}
|
||||||
@@ -149,5 +149,62 @@
|
|||||||
"sanctuary.heritage.unavailable": "This survey requires a recent Sanctuary world with experimental structures enabled.",
|
"sanctuary.heritage.unavailable": "This survey requires a recent Sanctuary world with experimental structures enabled.",
|
||||||
"sanctuary.heritage.header": "Abandoned infrastructure: %s planned sites and %s links. Entrance coordinates:",
|
"sanctuary.heritage.header": "Abandoned infrastructure: %s planned sites and %s links. Entrance coordinates:",
|
||||||
"sanctuary.heritage.entry": "%s : %s / %s / %s",
|
"sanctuary.heritage.entry": "%s : %s / %s / %s",
|
||||||
"sanctuary.transit.entry": "Industrial galleries — entrance: %s %s %s"
|
"sanctuary.transit.entry": "Industrial galleries — entrance: %s %s %s",
|
||||||
|
"key.category.sanctuary.sanctuary": "Sanctuary",
|
||||||
|
"sanctuary.blocodex.key": "Open Blocodex",
|
||||||
|
"sanctuary.blocodex.title": "Sanctuary Blocodex",
|
||||||
|
"sanctuary.blocodex.search": "Search blocks or identifiers…",
|
||||||
|
"sanctuary.blocodex.refresh": "Refresh",
|
||||||
|
"sanctuary.blocodex.filter": "Filter",
|
||||||
|
"sanctuary.blocodex.help": "About states",
|
||||||
|
"sanctuary.blocodex.help_text": "States are independent and personal.\nSeen: the first block looked at, up to 32 blocks away, without seeing through obstacles.\nMined: the Minecraft counter, following tool rules and excluding creative mode.\nOwned: carried or picked up at least once, even if you no longer have it.\nDropped: block items dropped by you, not generated loot.\nPlaced: blocks actually placed by you, including creative mode.\nInventory: carried stock, including equipment and the cursor; excluding chests, Ender storage and box contents.\nUnknown: no recorded history. Catalogue names remain visible.",
|
||||||
|
"sanctuary.blocodex.previous": "Previous page",
|
||||||
|
"sanctuary.blocodex.next": "Next page",
|
||||||
|
"sanctuary.blocodex.page": "Page %s / %s",
|
||||||
|
"sanctuary.blocodex.summary": "Known: %s / %s · Results: %s",
|
||||||
|
"sanctuary.blocodex.loading": "Loading your block knowledge…",
|
||||||
|
"sanctuary.blocodex.unavailable": "Blocodex unavailable",
|
||||||
|
"sanctuary.blocodex.unavailable_hint": "Server data is currently unavailable.",
|
||||||
|
"sanctuary.blocodex.empty": "No blocks match this search.",
|
||||||
|
"sanctuary.blocodex.known": "Known",
|
||||||
|
"sanctuary.blocodex.unknown": "Unknown",
|
||||||
|
"sanctuary.blocodex.yes": "Yes",
|
||||||
|
"sanctuary.blocodex.no": "No",
|
||||||
|
"sanctuary.blocodex.seen": "Seen: %s",
|
||||||
|
"sanctuary.blocodex.mined": "Mined: %s",
|
||||||
|
"sanctuary.blocodex.possessed": "Owned: %s",
|
||||||
|
"sanctuary.blocodex.dropped": "Dropped: %s",
|
||||||
|
"sanctuary.blocodex.placed": "Placed: %s",
|
||||||
|
"sanctuary.blocodex.carried": "Inventory: %s",
|
||||||
|
"sanctuary.blocodex.narration": "%s. %s. Seen: %s. Mined: %s. Ever owned: %s. Dropped: %s. Placed: %s. In inventory: %s.",
|
||||||
|
"sanctuary.blocodex.filter.all": "All blocks",
|
||||||
|
"sanctuary.blocodex.filter.unknown": "Unknown",
|
||||||
|
"sanctuary.blocodex.filter.known": "Known",
|
||||||
|
"sanctuary.blocodex.filter.seen": "Seen",
|
||||||
|
"sanctuary.blocodex.filter.mined": "Mined",
|
||||||
|
"sanctuary.blocodex.filter.possessed": "Ever owned",
|
||||||
|
"sanctuary.blocodex.filter.dropped": "Dropped",
|
||||||
|
"sanctuary.blocodex.filter.placed": "Placed",
|
||||||
|
"sanctuary.blocodex.filter.carried": "In inventory",
|
||||||
|
"sanctuary.census.scope": "Loaded areas only — census at tick %s.",
|
||||||
|
"sanctuary.census.terrain": "%s — complete chunks: %s/%s; counted sections: %s/%s; oldest pending section: %s ticks.",
|
||||||
|
"sanctuary.census.stocks": "Present items — players: %s; block containers: %s; entity containers: %s; on the ground: %s.",
|
||||||
|
"sanctuary.census.pending_loot": "Containers with unresolved loot, excluded from the census: %s.",
|
||||||
|
"sanctuary.census.help": "Details: /sanctuary census block <id> or /sanctuary census item <id>. Unloaded areas and stocks outside coverage remain unknown.",
|
||||||
|
"sanctuary.census.unknown_block": "Unknown block identifier: %s.",
|
||||||
|
"sanctuary.census.unknown_item": "Unknown item identifier: %s.",
|
||||||
|
"sanctuary.census.block": "%s in %s: %s blocks; coverage: %s/%s loaded sections.",
|
||||||
|
"sanctuary.census.item": "%s — players: %s; block containers: %s; entity containers: %s; on the ground: %s.",
|
||||||
|
"sanctuary.history.summary": "Activity on %s — %s rows; save pending: %s.",
|
||||||
|
"sanctuary.history.row": "%s — %s / %s: %s units.",
|
||||||
|
"sanctuary.history.missing": "No data recorded for %s.",
|
||||||
|
"sanctuary.history.limited": "Showing %s of %s rows.",
|
||||||
|
"sanctuary.history.error": "Material activity history unavailable: %s.",
|
||||||
|
"sanctuary.history.date_invalid": "Invalid date: %s. Use YYYY-MM-DD (UTC).",
|
||||||
|
"sanctuary.history.action.mined": "Mined",
|
||||||
|
"sanctuary.history.action.placed": "Placed",
|
||||||
|
"sanctuary.history.action.picked_up": "Picked up",
|
||||||
|
"sanctuary.history.action.dropped": "Dropped",
|
||||||
|
"sanctuary.history.action.crafted": "Crafted",
|
||||||
|
"sanctuary.history.scope": "Observed actions since installation, grouped by UTC day across the server. These records do not measure consumption or ownership transfers."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,5 +149,62 @@
|
|||||||
"sanctuary.heritage.unavailable": "Ce relevé nécessite un monde Sanctuary récent avec les structures expérimentales activées.",
|
"sanctuary.heritage.unavailable": "Ce relevé nécessite un monde Sanctuary récent avec les structures expérimentales activées.",
|
||||||
"sanctuary.heritage.header": "Anciennes infrastructures : %s lieux et %s liaisons prévus. Coordonnées des entrées :",
|
"sanctuary.heritage.header": "Anciennes infrastructures : %s lieux et %s liaisons prévus. Coordonnées des entrées :",
|
||||||
"sanctuary.heritage.entry": "%s : %s / %s / %s",
|
"sanctuary.heritage.entry": "%s : %s / %s / %s",
|
||||||
"sanctuary.transit.entry": "Galeries industrielles — entrée : %s %s %s"
|
"sanctuary.transit.entry": "Galeries industrielles — entrée : %s %s %s",
|
||||||
|
"key.category.sanctuary.sanctuary": "Sanctuary",
|
||||||
|
"sanctuary.blocodex.key": "Ouvrir le Blocodex",
|
||||||
|
"sanctuary.blocodex.title": "Blocodex Sanctuary",
|
||||||
|
"sanctuary.blocodex.search": "Rechercher un bloc ou son identifiant…",
|
||||||
|
"sanctuary.blocodex.refresh": "Actualiser",
|
||||||
|
"sanctuary.blocodex.filter": "Filtre",
|
||||||
|
"sanctuary.blocodex.help": "Les états ?",
|
||||||
|
"sanctuary.blocodex.help_text": "Les états sont indépendants et personnels.\nVu : premier bloc regardé, jusqu’à 32 blocs, sans traverser les obstacles.\nMiné : compteur Minecraft, selon les règles de l’outil et hors créatif.\nPossédé : déjà porté ou ramassé, même si vous ne l’avez plus.\nJeté : objets-blocs jetés par vous, pas les butins produits.\nPosé : blocs réellement placés par vous, créatif inclus.\nInventaire : stock porté, équipement et curseur compris ; hors coffres, Ender et contenu des boîtes.\nInconnu : aucune trace enregistrée. Les noms du catalogue restent visibles.",
|
||||||
|
"sanctuary.blocodex.previous": "Page précédente",
|
||||||
|
"sanctuary.blocodex.next": "Page suivante",
|
||||||
|
"sanctuary.blocodex.page": "Page %s / %s",
|
||||||
|
"sanctuary.blocodex.summary": "Connus : %s / %s · Résultats : %s",
|
||||||
|
"sanctuary.blocodex.loading": "Chargement de vos connaissances…",
|
||||||
|
"sanctuary.blocodex.unavailable": "Blocodex indisponible",
|
||||||
|
"sanctuary.blocodex.unavailable_hint": "Les données du serveur sont indisponibles.",
|
||||||
|
"sanctuary.blocodex.empty": "Aucun bloc ne correspond à cette recherche.",
|
||||||
|
"sanctuary.blocodex.known": "Connu",
|
||||||
|
"sanctuary.blocodex.unknown": "Inconnu",
|
||||||
|
"sanctuary.blocodex.yes": "Oui",
|
||||||
|
"sanctuary.blocodex.no": "Non",
|
||||||
|
"sanctuary.blocodex.seen": "Vu : %s",
|
||||||
|
"sanctuary.blocodex.mined": "Miné : %s",
|
||||||
|
"sanctuary.blocodex.possessed": "Possédé : %s",
|
||||||
|
"sanctuary.blocodex.dropped": "Jeté : %s",
|
||||||
|
"sanctuary.blocodex.placed": "Posé : %s",
|
||||||
|
"sanctuary.blocodex.carried": "Inventaire : %s",
|
||||||
|
"sanctuary.blocodex.narration": "%s. %s. Vu : %s. Miné : %s. Déjà possédé : %s. Jeté : %s. Posé : %s. En inventaire : %s.",
|
||||||
|
"sanctuary.blocodex.filter.all": "Tous les blocs",
|
||||||
|
"sanctuary.blocodex.filter.unknown": "Inconnus",
|
||||||
|
"sanctuary.blocodex.filter.known": "Connus",
|
||||||
|
"sanctuary.blocodex.filter.seen": "Vus",
|
||||||
|
"sanctuary.blocodex.filter.mined": "Minés",
|
||||||
|
"sanctuary.blocodex.filter.possessed": "Déjà possédés",
|
||||||
|
"sanctuary.blocodex.filter.dropped": "Jetés",
|
||||||
|
"sanctuary.blocodex.filter.placed": "Posés",
|
||||||
|
"sanctuary.blocodex.filter.carried": "En inventaire",
|
||||||
|
"sanctuary.census.scope": "Relevé des zones chargées uniquement — tick %s.",
|
||||||
|
"sanctuary.census.terrain": "%s — chunks complets : %s/%s ; sections comptées : %s/%s ; attente la plus ancienne : %s ticks.",
|
||||||
|
"sanctuary.census.stocks": "Objets présents — joueurs : %s ; conteneurs blocs : %s ; conteneurs entités : %s ; au sol : %s.",
|
||||||
|
"sanctuary.census.pending_loot": "Conteneurs au butin non généré, exclus du relevé : %s.",
|
||||||
|
"sanctuary.census.help": "Détail : /sanctuary census block <id> ou /sanctuary census item <id>. Les zones déchargées et stocks hors couverture restent inconnus.",
|
||||||
|
"sanctuary.census.unknown_block": "Identifiant de bloc inconnu : %s.",
|
||||||
|
"sanctuary.census.unknown_item": "Identifiant d’objet inconnu : %s.",
|
||||||
|
"sanctuary.census.block": "%s dans %s : %s blocs ; couverture : %s/%s sections chargées.",
|
||||||
|
"sanctuary.census.item": "%s — joueurs : %s ; conteneurs blocs : %s ; conteneurs entités : %s ; au sol : %s.",
|
||||||
|
"sanctuary.history.summary": "Activités du %s — %s lignes ; sauvegarde en attente : %s.",
|
||||||
|
"sanctuary.history.row": "%s — %s / %s : %s unités.",
|
||||||
|
"sanctuary.history.missing": "Aucune donnée enregistrée pour le %s.",
|
||||||
|
"sanctuary.history.limited": "%s lignes affichées sur %s.",
|
||||||
|
"sanctuary.history.error": "Historique des matériaux indisponible : %s.",
|
||||||
|
"sanctuary.history.date_invalid": "Date invalide : %s. Utiliser AAAA-MM-JJ (UTC).",
|
||||||
|
"sanctuary.history.action.mined": "Miné",
|
||||||
|
"sanctuary.history.action.placed": "Posé",
|
||||||
|
"sanctuary.history.action.picked_up": "Ramassé",
|
||||||
|
"sanctuary.history.action.dropped": "Jeté",
|
||||||
|
"sanctuary.history.action.crafted": "Fabriqué",
|
||||||
|
"sanctuary.history.scope": "Actions observées depuis l’installation, regroupées par jour UTC pour tout le serveur. Ces relevés ne mesurent pas une consommation ni un transfert de propriété."
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"replace": false,
|
||||||
|
"values": [
|
||||||
|
"#minecraft:logs",
|
||||||
|
"#minecraft:leaves",
|
||||||
|
"minecraft:stone",
|
||||||
|
"minecraft:granite",
|
||||||
|
"minecraft:diorite",
|
||||||
|
"minecraft:andesite",
|
||||||
|
"minecraft:tuff",
|
||||||
|
"minecraft:calcite",
|
||||||
|
"minecraft:dripstone_block",
|
||||||
|
"minecraft:deepslate",
|
||||||
|
"minecraft:cobbled_deepslate",
|
||||||
|
"minecraft:cobblestone",
|
||||||
|
"minecraft:mossy_cobblestone",
|
||||||
|
"minecraft:blackstone",
|
||||||
|
"minecraft:basalt",
|
||||||
|
"minecraft:smooth_basalt",
|
||||||
|
"minecraft:end_stone",
|
||||||
|
"minecraft:netherrack",
|
||||||
|
"minecraft:obsidian",
|
||||||
|
"minecraft:crying_obsidian",
|
||||||
|
"minecraft:sandstone",
|
||||||
|
"minecraft:red_sandstone",
|
||||||
|
"minecraft:dirt",
|
||||||
|
"minecraft:coarse_dirt",
|
||||||
|
"minecraft:grass_block",
|
||||||
|
"minecraft:podzol",
|
||||||
|
"minecraft:rooted_dirt",
|
||||||
|
"minecraft:mud",
|
||||||
|
"minecraft:clay",
|
||||||
|
"minecraft:gravel",
|
||||||
|
"minecraft:sand",
|
||||||
|
"minecraft:red_sand",
|
||||||
|
"minecraft:moss_block",
|
||||||
|
"minecraft:pale_moss_block",
|
||||||
|
"minecraft:snow_block",
|
||||||
|
"minecraft:ice",
|
||||||
|
"minecraft:packed_ice",
|
||||||
|
"minecraft:blue_ice",
|
||||||
|
"minecraft:terracotta",
|
||||||
|
"minecraft:bone_block",
|
||||||
|
"minecraft:brown_mushroom_block",
|
||||||
|
"minecraft:red_mushroom_block",
|
||||||
|
"minecraft:mushroom_stem",
|
||||||
|
"minecraft:amethyst_block"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"main": [
|
"main": [
|
||||||
"fr.koka.sanctuary.SanctuaryMod"
|
"fr.koka.sanctuary.SanctuaryMod"
|
||||||
|
],
|
||||||
|
"client": [
|
||||||
|
"fr.koka.sanctuary.client.SanctuaryKnowledgeClient"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mixins": [
|
"mixins": [
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
"package": "fr.koka.sanctuary.mixin",
|
"package": "fr.koka.sanctuary.mixin",
|
||||||
"compatibilityLevel": "JAVA_25",
|
"compatibilityLevel": "JAVA_25",
|
||||||
"mixins": [
|
"mixins": [
|
||||||
|
"CensusChunkMapAccessor",
|
||||||
|
"TerrainCensusSectionMixin",
|
||||||
|
"BlockKnowledgePlacementMixin",
|
||||||
|
"BlockKnowledgeStatsMixin",
|
||||||
|
"BlockKnowledgeContainerMixin",
|
||||||
"ExpansionChunkMapAccessor",
|
"ExpansionChunkMapAccessor",
|
||||||
"ExpansionCarverMixin",
|
"ExpansionCarverMixin",
|
||||||
"InitialSpawnMixin",
|
"InitialSpawnMixin",
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Plain-Java checks of independent evidence and fail-closed persistence. */
|
||||||
|
public final class BlockKnowledgeSmoke {
|
||||||
|
private static final UUID PLAYER = UUID.fromString("bf94124c-60ce-44fd-bc81-2be7e61bfcb0");
|
||||||
|
private static final UUID OTHER = UUID.fromString("21b477f6-5e84-4641-bc87-c1a36d6d012f");
|
||||||
|
private static final String STONE = "minecraft:stone";
|
||||||
|
private static final String DIRT = "minecraft:dirt";
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
independentEvidence();
|
||||||
|
Path scratch = Files.createTempDirectory("sanctuary-block-knowledge-");
|
||||||
|
try {
|
||||||
|
roundTripAndIsolation(scratch.resolve("players"));
|
||||||
|
invalidState(scratch.resolve("invalid"));
|
||||||
|
externalChanges(scratch.resolve("external"));
|
||||||
|
rejectOversized(scratch.resolve("oversized"));
|
||||||
|
System.out.println("Block knowledge smoke passed: independent sticky evidence, saturated placement counts, immutable snapshots, UUID isolation, strict schema and fail-closed atomic persistence");
|
||||||
|
} finally {
|
||||||
|
try (var paths = Files.walk(scratch)) {
|
||||||
|
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void independentEvidence() {
|
||||||
|
BlockKnowledgeMemory memory = new BlockKnowledgeMemory();
|
||||||
|
require(memory.entry(STONE).equals(new BlockKnowledgeMemory.Entry(false, false, 0)), "Untouched block must be unknown");
|
||||||
|
require(memory.entries().isEmpty(), "Reading unknown blocks must not create history");
|
||||||
|
require(memory.recordSeen(STONE) && !memory.recordSeen(STONE), "Seen must remain sticky and report changes");
|
||||||
|
require(memory.entry(STONE).equals(new BlockKnowledgeMemory.Entry(true, false, 0)), "Seeing must not invent possession or placement");
|
||||||
|
require(memory.recordPossessed(DIRT) && !memory.recordPossessed(DIRT), "Possession must remain sticky and report changes");
|
||||||
|
require(memory.entry(DIRT).equals(new BlockKnowledgeMemory.Entry(false, true, 0)), "Possession must not invent seen evidence");
|
||||||
|
memory.recordPlaced("minecraft:oak_log");
|
||||||
|
memory.recordPlaced("minecraft:oak_log");
|
||||||
|
require(memory.entry("minecraft:oak_log").equals(new BlockKnowledgeMemory.Entry(false, false, 2)), "Each placement must count independently");
|
||||||
|
Map<String, BlockKnowledgeMemory.Entry> snapshot = memory.entries();
|
||||||
|
memory.recordPossessed(STONE);
|
||||||
|
require(!snapshot.get(STONE).possessed(), "Snapshots must remain stable after mutation");
|
||||||
|
try { snapshot.clear(); throw new AssertionError("Snapshot was mutable"); }
|
||||||
|
catch (UnsupportedOperationException expected) { }
|
||||||
|
|
||||||
|
BlockKnowledgeMemory saturated = BlockKnowledgeMemory.restored(Map.of(STONE, new BlockKnowledgeMemory.Entry(true, true, Long.MAX_VALUE - 1)));
|
||||||
|
saturated.recordPlaced(STONE);
|
||||||
|
saturated.recordPlaced(STONE);
|
||||||
|
require(saturated.entry(STONE).placed() == Long.MAX_VALUE, "Placement overflow must saturate");
|
||||||
|
|
||||||
|
require(!new BlockKnowledge(STONE, false, false, 0, 0, 0, 0).known(), "Zero evidence must be unknown");
|
||||||
|
for (BlockKnowledge known : List.of(
|
||||||
|
new BlockKnowledge(STONE, true, false, 0, 0, 0, 0),
|
||||||
|
new BlockKnowledge(STONE, false, true, 0, 0, 0, 0),
|
||||||
|
new BlockKnowledge(STONE, false, false, 1, 0, 0, 0),
|
||||||
|
new BlockKnowledge(STONE, false, false, 0, 1, 0, 0),
|
||||||
|
new BlockKnowledge(STONE, false, false, 0, 0, 1, 0),
|
||||||
|
new BlockKnowledge(STONE, false, false, 0, 0, 0, 1))) {
|
||||||
|
require(known.known(), "Each type of evidence must independently establish knowledge");
|
||||||
|
}
|
||||||
|
rejectArgument(() -> memory.recordSeen("stone"), "Unnamespaced ID");
|
||||||
|
rejectArgument(() -> memory.recordPossessed("minecraft:Stone"), "Invalid ID characters");
|
||||||
|
rejectArgument(() -> new BlockKnowledgeMemory.Entry(false, false, -1), "Negative placement count");
|
||||||
|
rejectArgument(() -> new BlockKnowledge(STONE, false, false, -1, 0, 0, 0), "Negative mined count");
|
||||||
|
rejectArgument(() -> new BlockKnowledge(STONE, false, false, 0, -1, 0, 0), "Negative dropped count");
|
||||||
|
rejectArgument(() -> new BlockKnowledge(STONE, false, false, 0, 0, -1, 0), "Negative placed count");
|
||||||
|
rejectArgument(() -> new BlockKnowledge(STONE, false, false, 0, 0, 0, -1), "Negative carried count");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTripAndIsolation(Path directory) throws Exception {
|
||||||
|
BlockKnowledgeStore store = new BlockKnowledgeStore(directory, PLAYER);
|
||||||
|
BlockKnowledgeMemory memory = store.load();
|
||||||
|
require(memory.entries().isEmpty() && !Files.exists(directory), "Missing state must not create disk files on load");
|
||||||
|
store.save(memory);
|
||||||
|
require(!Files.exists(directory), "An unchanged empty snapshot must stay in memory only");
|
||||||
|
memory.recordSeen(STONE);
|
||||||
|
memory.recordPossessed(DIRT);
|
||||||
|
memory.recordPlaced(STONE);
|
||||||
|
memory.recordPlaced(STONE);
|
||||||
|
memory.recordPossessed("removed_mod:materials/ancient_stone");
|
||||||
|
store.save(memory);
|
||||||
|
Path file = directory.resolve(PLAYER + ".json");
|
||||||
|
byte[] initial = Files.readAllBytes(file);
|
||||||
|
require(new BlockKnowledgeStore(directory, PLAYER).load().entries().equals(memory.entries()), "Player evidence must survive a restart, including absent mod identifiers");
|
||||||
|
store.save(memory);
|
||||||
|
require(Arrays.equals(initial, Files.readAllBytes(file)), "Unchanged save must preserve exact file bytes");
|
||||||
|
String json = Files.readString(file);
|
||||||
|
require(!json.contains("mined") && !json.contains("dropped") && !json.contains("carried"), "Sidecar must not duplicate vanilla counters or current inventory");
|
||||||
|
|
||||||
|
BlockKnowledgeStore otherStore = new BlockKnowledgeStore(directory, OTHER);
|
||||||
|
BlockKnowledgeMemory other = otherStore.load();
|
||||||
|
require(other.entries().isEmpty(), "A different UUID must not inherit another player's knowledge");
|
||||||
|
other.recordSeen("minecraft:sand");
|
||||||
|
otherStore.save(other);
|
||||||
|
require(Arrays.equals(initial, Files.readAllBytes(file)), "A second player must not alter the first player's file");
|
||||||
|
require(new BlockKnowledgeStore(directory, OTHER).load().entries().equals(other.entries()), "The second player must have an independent round trip");
|
||||||
|
|
||||||
|
Path unloadedDirectory = directory.resolve("unloaded");
|
||||||
|
reject(() -> new BlockKnowledgeStore(unloadedDirectory, PLAYER).save(memory), "Save without loading");
|
||||||
|
require(!Files.exists(unloadedDirectory), "Rejected save before load must not create files");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void invalidState(Path directory) throws Exception {
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
String valid = "{\"schema\":1,\"player\":\"" + PLAYER + "\",\"blocks\":{\"minecraft:stone\":{\"seen\":true,\"possessed\":false,\"placed\":2}}}";
|
||||||
|
List<String> invalid = List.of(
|
||||||
|
"{",
|
||||||
|
"[]",
|
||||||
|
valid + " {}",
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":2"),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":1,\"schema\":1"),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":\"1\""),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":1.0"),
|
||||||
|
valid.replace("\"schema\":1", "/* comment */\"schema\":1"),
|
||||||
|
valid.replace(PLAYER.toString(), OTHER.toString()),
|
||||||
|
valid.replace("minecraft:stone", "invalid block"),
|
||||||
|
valid.replace("\"placed\":2", "\"placed\":-1"),
|
||||||
|
valid.replace("\"placed\":2", "\"placed\":9223372036854775808"),
|
||||||
|
valid.replace("\"placed\":2", "\"placed\":2.5"),
|
||||||
|
valid.replace("\"seen\":true", "\"seen\":\"true\""),
|
||||||
|
valid.replace("\"seen\":true,", ""),
|
||||||
|
valid.replace("\"placed\":2", "\"placed\":2,\"mined\":2"),
|
||||||
|
valid.replace("\"seen\":true", "\"seen\":true,\"seen\":false"),
|
||||||
|
valid.replace("\"blocks\":{", "\"blocks\":{\"minecraft:stone\":{\"seen\":false,\"possessed\":false,\"placed\":0},"));
|
||||||
|
for (int index = 0; index < invalid.size(); index++) {
|
||||||
|
Path testDirectory = directory.resolve("case-" + index);
|
||||||
|
Files.createDirectory(testDirectory);
|
||||||
|
Path file = testDirectory.resolve(PLAYER + ".json");
|
||||||
|
Files.writeString(file, invalid.get(index));
|
||||||
|
byte[] before = Files.readAllBytes(file);
|
||||||
|
BlockKnowledgeStore store = new BlockKnowledgeStore(testDirectory, PLAYER);
|
||||||
|
reject(store::load, "Invalid JSON case " + index);
|
||||||
|
BlockKnowledgeMemory replacement = new BlockKnowledgeMemory();
|
||||||
|
replacement.recordSeen(DIRT);
|
||||||
|
reject(() -> store.save(replacement), "Saving after failed load " + index);
|
||||||
|
require(Arrays.equals(before, Files.readAllBytes(file)), "Failed load/save replaced invalid evidence " + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void externalChanges(Path directory) throws Exception {
|
||||||
|
BlockKnowledgeStore original = new BlockKnowledgeStore(directory, PLAYER);
|
||||||
|
BlockKnowledgeMemory first = original.load();
|
||||||
|
first.recordSeen(STONE);
|
||||||
|
original.save(first);
|
||||||
|
BlockKnowledgeStore stale = new BlockKnowledgeStore(directory, PLAYER);
|
||||||
|
BlockKnowledgeMemory second = stale.load();
|
||||||
|
first.recordPossessed(STONE);
|
||||||
|
original.save(first);
|
||||||
|
Path file = directory.resolve(PLAYER + ".json");
|
||||||
|
byte[] fresh = Files.readAllBytes(file);
|
||||||
|
second.recordSeen(DIRT);
|
||||||
|
reject(() -> stale.save(second), "Stale writer");
|
||||||
|
require(Arrays.equals(fresh, Files.readAllBytes(file)), "Stale writer replaced newer evidence");
|
||||||
|
Files.writeString(file, "external corruption");
|
||||||
|
first.recordSeen(DIRT);
|
||||||
|
reject(() -> original.save(first), "Corruption after loading");
|
||||||
|
require(Files.readString(file).equals("external corruption"), "External corruption was silently replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rejectOversized(Path directory) throws Exception {
|
||||||
|
Files.createDirectory(directory);
|
||||||
|
Path file = directory.resolve(PLAYER + ".json");
|
||||||
|
byte[] oversized = new byte[16 * 1024 * 1024 + 1];
|
||||||
|
Arrays.fill(oversized, (byte) ' ');
|
||||||
|
Files.write(file, oversized);
|
||||||
|
BlockKnowledgeStore store = new BlockKnowledgeStore(directory, PLAYER);
|
||||||
|
reject(store::load, "Oversized sidecar");
|
||||||
|
reject(() -> store.save(new BlockKnowledgeMemory()), "Saving after oversized sidecar");
|
||||||
|
require(Files.size(file) == oversized.length, "Oversized evidence must remain available for repair");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reject(IOAction action, String description) throws Exception {
|
||||||
|
try { action.run(); } catch (IOException expected) { return; }
|
||||||
|
throw new AssertionError(description + " was accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rejectArgument(Runnable action, String description) {
|
||||||
|
try { action.run(); } catch (IllegalArgumentException expected) { return; }
|
||||||
|
throw new AssertionError(description + " was accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface private interface IOAction { void run() throws Exception; }
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package fr.koka.sanctuary.knowledge.history;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Daily material identity, immutable snapshots and protection of prior history. */
|
||||||
|
public final class MaterialActivitySmoke {
|
||||||
|
private static final LocalDate DAY = LocalDate.of(2026, 9, 10);
|
||||||
|
private static final MaterialActivity MINE = new MaterialActivity("mine", "block", "minecraft:stone");
|
||||||
|
private static final MaterialActivity PLACE = new MaterialActivity("place", "block", "minecraft:stone");
|
||||||
|
private static final MaterialActivity PICKUP = new MaterialActivity("pickup", "item", "minecraft:stone");
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
identities();
|
||||||
|
Path scratch = Files.createTempDirectory("sanctuary-material-activity-");
|
||||||
|
try {
|
||||||
|
roundTrips(scratch.resolve("days"));
|
||||||
|
corruption(scratch.resolve("invalid"));
|
||||||
|
changedHistory(scratch.resolve("external"));
|
||||||
|
sizeLimit(scratch.resolve("oversized"));
|
||||||
|
System.out.println("Material activity smoke passed: ordered action/registry identities, independent dates, immutable exact counts, absent mods, strict JSON and fail-closed daily persistence");
|
||||||
|
} finally {
|
||||||
|
try (var paths = Files.walk(scratch)) {
|
||||||
|
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void identities() {
|
||||||
|
List<MaterialActivity> sorted = List.of(
|
||||||
|
new MaterialActivity("craft", "item", "minecraft:stone"),
|
||||||
|
new MaterialActivity("mine", "block", "minecraft:dirt"), MINE,
|
||||||
|
new MaterialActivity("mine", "item", "minecraft:stone"), PICKUP, PLACE);
|
||||||
|
require(sorted.equals(sorted.stream().sorted().toList()), "Tuple order must be action, then registry, then material");
|
||||||
|
require(MINE.compareTo(new MaterialActivity("mine", "block", "minecraft:stone")) == 0,
|
||||||
|
"Natural ordering must agree with equal tuple identities");
|
||||||
|
rejectArgument(() -> new MaterialActivity("1mine", "block", "minecraft:stone"), "Non-letter action prefix");
|
||||||
|
rejectArgument(() -> new MaterialActivity("MINE", "block", "minecraft:stone"), "Uppercase action");
|
||||||
|
rejectArgument(() -> new MaterialActivity("m".repeat(65), "block", "minecraft:stone"), "Oversized action");
|
||||||
|
rejectArgument(() -> new MaterialActivity("mine", "entity", "minecraft:stone"), "Unsupported registry");
|
||||||
|
rejectArgument(() -> new MaterialActivity("mine", "block", "stone"), "Unnamespaced material");
|
||||||
|
rejectArgument(() -> new MaterialActivity("mine", "block", "minecraft:Stone"), "Invalid material characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void roundTrips(Path directory) throws Exception {
|
||||||
|
MaterialActivityStore store = new MaterialActivityStore(directory, DAY);
|
||||||
|
require(store.path().equals(directory.toAbsolutePath().resolve("2026-09-10.json")), "Filename must use its ISO date");
|
||||||
|
Map<MaterialActivity, Long> empty = store.load();
|
||||||
|
require(empty.isEmpty() && !Files.exists(directory), "Missing activity must stay in memory until changed");
|
||||||
|
store.save(empty);
|
||||||
|
require(!Files.exists(directory), "Saving the unchanged empty day must not create files");
|
||||||
|
MaterialActivity absentMod = new MaterialActivity("craft", "item", "removed_mod:materials/ancient_brick");
|
||||||
|
MaterialActivity itemMine = new MaterialActivity("mine", "item", "minecraft:stone");
|
||||||
|
Map<MaterialActivity, Long> counts = new LinkedHashMap<>();
|
||||||
|
counts.put(PLACE, 13L);
|
||||||
|
counts.put(PICKUP, 17L);
|
||||||
|
counts.put(itemMine, 19L);
|
||||||
|
counts.put(MINE, 23L);
|
||||||
|
// Saturation belongs to the caller; an already saturated total must remain exact on disk.
|
||||||
|
counts.put(absentMod, Long.MAX_VALUE);
|
||||||
|
store.save(counts);
|
||||||
|
byte[] before = Files.readAllBytes(store.path());
|
||||||
|
Map<MaterialActivity, Long> loaded = new MaterialActivityStore(directory, DAY).load();
|
||||||
|
require(loaded.equals(counts), "Different actions and registries of one material must round-trip separately");
|
||||||
|
require(loaded.get(absentMod) == Long.MAX_VALUE, "Absent mod identifiers and saturated long counters must survive reload");
|
||||||
|
require(new ArrayList<>(loaded.keySet()).equals(loaded.keySet().stream().sorted().toList()), "Loaded iteration order must be deterministic");
|
||||||
|
try { loaded.put(MINE, 99L); throw new AssertionError("Loaded daily activity was mutable"); }
|
||||||
|
catch (UnsupportedOperationException expected) { }
|
||||||
|
counts.put(MINE, 99L);
|
||||||
|
require(loaded.get(MINE) == 23L, "Loaded snapshot must not share the caller's mutable map");
|
||||||
|
store.save(loaded);
|
||||||
|
require(Arrays.equals(before, Files.readAllBytes(store.path())), "Unchanged save must retain exact bytes");
|
||||||
|
|
||||||
|
MaterialActivityStore next = new MaterialActivityStore(directory, DAY.plusDays(1));
|
||||||
|
require(next.load().isEmpty(), "The next day must not inherit the previous day's totals");
|
||||||
|
next.save(Map.of(MINE, 5L));
|
||||||
|
require(Arrays.equals(before, Files.readAllBytes(store.path())), "Saving another day must preserve previous history");
|
||||||
|
require(new MaterialActivityStore(directory, DAY.plusDays(1)).load().equals(Map.of(MINE, 5L)), "Second-day totals must reload independently");
|
||||||
|
Path uninitialized = directory.resolve("uninitialized");
|
||||||
|
reject(() -> new MaterialActivityStore(uninitialized, DAY).save(loaded), "Save without load");
|
||||||
|
require(!Files.exists(uninitialized), "Rejected save before load must not create files");
|
||||||
|
|
||||||
|
for (long invalid : List.of(0L, -1L)) {
|
||||||
|
MaterialActivityStore bad = new MaterialActivityStore(directory.resolve("bad-count-" + invalid), DAY);
|
||||||
|
bad.load();
|
||||||
|
reject(() -> bad.save(Map.of(MINE, invalid)), "Nonpositive count " + invalid);
|
||||||
|
require(!Files.exists(bad.path()), "Invalid counts must not be written");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void corruption(Path directory) throws Exception {
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
String tuple = "{\"action\":\"mine\",\"registry\":\"block\",\"material\":\"minecraft:stone\",\"count\":2}";
|
||||||
|
String valid = "{\"schema\":1,\"date\":\"2026-09-10\",\"entries\":[" + tuple + "]}";
|
||||||
|
List<String> invalid = List.of(
|
||||||
|
"{", "[]", valid + " {}",
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":2"),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":1,\"schema\":1"),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":\"1\""),
|
||||||
|
valid.replace("\"schema\":1", "\"schema\":1.0"),
|
||||||
|
valid.replace("\"schema\":1", "/*comment*/\"schema\":1"),
|
||||||
|
valid.replace("2026-09-10", "2026-09-11"),
|
||||||
|
valid.replace("2026-09-10", "2026-9-10"),
|
||||||
|
valid.replace("2026-09-10", "2026-09-10T00:00:00Z"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":0"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":-1"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":2.5"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":9223372036854775808"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":\"2\""),
|
||||||
|
valid.replace("\"count\":2", "\"count\":2,\"count\":3"),
|
||||||
|
valid.replace("\"count\":2", "\"count\":2,\"ballast\":3"),
|
||||||
|
valid.replace("\"action\":\"mine\",", ""),
|
||||||
|
valid.replace("\"mine\"", "\"Mine\""),
|
||||||
|
valid.replace("\"block\"", "\"entity\""),
|
||||||
|
valid.replace("minecraft:stone", "stone"),
|
||||||
|
valid.replace(tuple, tuple + "," + tuple));
|
||||||
|
for (int index = 0; index < invalid.size(); index++) {
|
||||||
|
Path testDirectory = directory.resolve("case-" + index);
|
||||||
|
Files.createDirectory(testDirectory);
|
||||||
|
MaterialActivityStore store = new MaterialActivityStore(testDirectory, DAY);
|
||||||
|
Files.writeString(store.path(), invalid.get(index));
|
||||||
|
byte[] before = Files.readAllBytes(store.path());
|
||||||
|
reject(store::load, "Invalid daily activity " + index);
|
||||||
|
reject(() -> store.save(Map.of(MINE, 99L)), "Save after failed load " + index);
|
||||||
|
require(Arrays.equals(before, Files.readAllBytes(store.path())), "Invalid history was overwritten " + index);
|
||||||
|
}
|
||||||
|
MaterialActivityStore wrongEncoding = new MaterialActivityStore(directory, DAY);
|
||||||
|
byte[] invalidUtf8 = {(byte) 0xc3, (byte) 0x28};
|
||||||
|
Files.write(wrongEncoding.path(), invalidUtf8);
|
||||||
|
reject(wrongEncoding::load, "Invalid UTF-8");
|
||||||
|
reject(() -> wrongEncoding.save(Map.of(MINE, 1L)), "Save after invalid UTF-8");
|
||||||
|
require(Arrays.equals(invalidUtf8, Files.readAllBytes(wrongEncoding.path())), "Invalid UTF-8 evidence was replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void changedHistory(Path directory) throws Exception {
|
||||||
|
MaterialActivityStore original = new MaterialActivityStore(directory, DAY);
|
||||||
|
original.load();
|
||||||
|
original.save(Map.of(MINE, 2L));
|
||||||
|
MaterialActivityStore stale = new MaterialActivityStore(directory, DAY);
|
||||||
|
stale.load();
|
||||||
|
original.save(Map.of(MINE, 3L));
|
||||||
|
byte[] fresh = Files.readAllBytes(original.path());
|
||||||
|
reject(() -> stale.save(Map.of(MINE, 4L)), "Stale daily writer");
|
||||||
|
require(Arrays.equals(fresh, Files.readAllBytes(original.path())), "Stale writer replaced a newer day snapshot");
|
||||||
|
Files.writeString(original.path(), "external corruption");
|
||||||
|
reject(() -> original.save(Map.of(MINE, 5L)), "Damage after load");
|
||||||
|
require(Files.readString(original.path()).equals("external corruption"), "External corruption was silently replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sizeLimit(Path directory) throws Exception {
|
||||||
|
Files.createDirectory(directory);
|
||||||
|
MaterialActivityStore store = new MaterialActivityStore(directory, DAY);
|
||||||
|
byte[] oversized = new byte[16 * 1024 * 1024 + 1];
|
||||||
|
Arrays.fill(oversized, (byte) ' ');
|
||||||
|
Files.write(store.path(), oversized);
|
||||||
|
reject(store::load, "Oversized daily activity");
|
||||||
|
reject(() -> store.save(Map.of(MINE, 1L)), "Save after oversized daily activity");
|
||||||
|
require(Files.size(store.path()) == oversized.length, "Oversized history must remain available for repair");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reject(IOAction action, String description) throws Exception {
|
||||||
|
try { action.run(); } catch (IOException expected) { return; }
|
||||||
|
throw new AssertionError(description + " was accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rejectArgument(Runnable action, String description) {
|
||||||
|
try { action.run(); } catch (IllegalArgumentException expected) { return; }
|
||||||
|
throw new AssertionError(description + " was accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require(boolean condition, String message) {
|
||||||
|
if (!condition) throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface private interface IOAction { void run() throws Exception; }
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
name = "Sanctuary"
|
name = "Sanctuary"
|
||||||
author = "KOKA99CAB"
|
author = "KOKA99CAB"
|
||||||
version = "0.1.0-alpha.22"
|
version = "0.1.0-alpha.23"
|
||||||
pack-format = "packwiz:1.1.0"
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
[index]
|
[index]
|
||||||
|
|||||||
Reference in New Issue
Block a user