Compare commits

..
Author SHA1 Message Date
koka 759ca89b5f Merge pull request 'fix(onlyfun): livrer les livres enchantés des lootboxes' (#110) from fix/issue-106-enchanted-book-lootbox into fix/issue-98-phantom-quest
Reviewed-on: #110
2026-08-31 03:01:29 +00:00
koka c40af33e67 Merge pull request 'fix(anotherworld): reconnaître l’épée en titane comme une épée' (#111) from fix/issue-102-squid-sword into fix/issue-106-enchanted-book-lootbox
Reviewed-on: #111
2026-08-31 03:01:22 +00:00
koka 8c43bd04e2 Merge pull request 'fix(iliketomoveit): allumer les portails avec les boules de feu' (#113) from fix/issue-101-portal-projectile-ignition into fix/issue-102-squid-sword
Reviewed-on: #113
2026-08-31 03:01:05 +00:00
koka 95d1658244 Merge pull request 'fix(anotherworld): tondre les moutons avec les cisailles en titane' (#114) from fix/issue-104-titanium-shears into fix/issue-101-portal-projectile-ignition
Reviewed-on: #114
2026-08-31 03:00:56 +00:00
koka 77a933180e Merge pull request 'feat(sanctuary): appliquer les bannières sur une cape blanche' (#115) from fix/issue-103-banner-cape into fix/issue-104-titanium-shears
Reviewed-on: #115
2026-08-31 03:00:44 +00:00
koka b4609916d1 feat(sanctuary): apply banner patterns to white capes 2026-08-31 04:53:33 +02:00
koka aae4862e8b fix(anotherworld): shear sheep with titanium shears 2026-08-31 04:36:38 +02:00
koka 3079faef50 fix(iliketomoveit): ignite portals with player fireballs 2026-08-31 04:30:15 +02:00
koka 197fe91185 fix(anotherworld): register titanium sword behavior 2026-08-31 04:21:38 +02:00
koka d438b51084 fix(onlyfun): deliver enchanted book rewards 2026-08-31 04:15:18 +02:00
koka 233ca44935 fix(sanctuary): replace impossible phantom quests 2026-08-31 04:10:42 +02:00
koka d2abbb3155 fix(anotherworld): require a vault before raiding
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-31 04:01:46 +02:00
koka 7a2f697024 Merge pull request 'release: publier les dernières PR dans Packwiz alpha.222' (#100) from release/26.2.0-alpha.222 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #100
2026-08-30 13:33:43 +00:00
koka 4d0750e993 release: update Packwiz to alpha.222
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:29:24 +02:00
koka c49673801f Merge pull request 'fix(sanctuary): simplifier le suivi de quetes' (#99) from fix/quest-tracker-transparent into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #99
2026-08-30 13:15:18 +00:00
koka 5386c7ac8a fix(sanctuary): simplifier le suivi de quetes
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 15:09:01 +02:00
koka eb25625a45 Merge pull request 'feat(sanctuary): ajouter un suivi d'avancement des quetes' (#86) from feature/quest-tracker-hud into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #86
2026-08-30 13:00:21 +00:00
ChrisM-PekandCursor 05c445eaeb Merge origin/main into feature/quest-tracker-hud
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:50:24 +02:00
koka 788928f2d9 Merge pull request 'feat(redstoner): rechercher et vider sans glissement dans le terminal' (#97) from feature/storage-terminal-search into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #97
2026-08-30 08:06:20 +00:00
koka 5d3db9bb42 chore(redstoner): defer packwiz refresh
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 10:01:50 +02:00
koka fd528abb9d feat(redstoner): add storage terminal keyboard search 2026-08-30 10:00:31 +02:00
koka 40acccc818 feat(redstoner): add storage terminal keyboard search
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 08:43:17 +02:00
koka 62c8c87eb0 Merge pull request 'fix(ambiance): restaurer la vitesse native du disque blanc (alpha.221)' (#96) from fix/white-disc-native-rate into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #96
2026-08-30 06:03:00 +00:00
koka b5dab3b5f6 chore(release): prepare alpha.221 native audio
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 07:07:36 +02:00
koka 90790161db Merge remote-tracking branch 'origin/feature/storage-terminal-menu' into fix/white-disc-native-rate 2026-08-30 06:58:50 +02:00
koka e8dee9b7f8 feat(redstoner): integrate storage terminal menu
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-30 06:55:18 +02:00
koka 6fc93ca732 fix(ambiance): preserve white disc native audio rate 2026-08-30 06:51:22 +02:00
koka 8787969347 Merge pull request 'feat(alpha): intégrer l’île Indev, son portail et ses textures locales' (#93) from integration/alpha-indev into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #93
2026-08-30 03:10:56 +00:00
ChrisM-PekandCursor 6e58d44c71 feat(sanctuary): assister les IDs de quete et limiter le tracker aux quetes acceptees
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 00:44:38 +02:00
ChrisM-PekandCursor d45afc7767 feat(sanctuary): ajouter un suivi d'avancement des quetes
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 23:45:40 +02:00
92 changed files with 3278 additions and 890 deletions
+52
View File
@@ -6,6 +6,58 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
## Non distribué
Modules modifiés : **Another World `0.0.0-alpha.58`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`** et **Sanctuary `0.0.0-alpha.126`**. Le pack public reste temporairement en `26.2.0-alpha.222` jusqu'à la publication.
- exige que l'attaquant possède son propre coffre-fort actif avant de pouvoir lancer ou achever le forage d'un autre coffre-fort ;
- refuse aussi le crédit du butin côté données si le coffre-fort de l'attaquant disparaît pendant le forage, sans changer les données v4 ni les sauvegardes existantes ;
- ajoute le refus explicite en français, anglais et russe.
- remplace les quêtes saphir de Phantoms, désactivés dans Sanctuary, par les fantômes disponibles dIt's Alive ;
- migre automatiquement les contrats Phantom déjà sauvegardés vers `itsalive:ghost` en conservant leur progression, leur réservation et leur récompense ; les données du panneau de quêtes passent de v1 à v2 tandis que les données Sanctuary v11 et le protocole 22 restent inchangés.
- autorise la récompense « livre enchanté » des lootboxes Only Fun à être remise au joueur, tout en la conservant hors du Shop ; les données Only Fun v2 et le protocole 3 restent inchangés.
- déclare l’épée en titane dans le contrat vanilla `minecraft:swords`, afin que ses attaques et propriétés d’épée sappliquent aussi aux créatures aquatiques comme les poulpes.
- permet aux petites et grandes boules de feu lancées par les pouvoirs de compagnons dallumer un cadre de portail valide, avec les mêmes contrôles serveur et sans explosion destructrice lorsque le tir atteint ce cadre.
- permet aux cisailles en titane de tondre les moutons avec le même butin et les mêmes événements que les cisailles vanilla, tout en conservant leur propriété incassable.
- ajoute une cape blanche personnalisable en la combinant avec nimporte quelle bannière ; sa couleur et jusqu’à 16 motifs sont conservés, synchronisés et rendus avec la physique de cape vanilla.
## `26.2.0-alpha.222`
Modules modifiés : **Red-Stoner `0.0.0-alpha.13`** et **Sanctuary `0.0.0-alpha.124`**. Les huit autres modules conservent leur version issue de l'alpha.221.
- ajoute au terminal de stockage une recherche clavier focalisée à l'ouverture, compatible avec les noms affichés, les IDs, les traductions, les accents et Unicode ;
- affiche les grandes quantités du terminal sous forme compacte (`1K`, `1.2K`, `12K`, `1.2M`) tout en conservant la valeur exacte dans l'infobulle ;
- stabilise les emplacements du terminal lorsqu'une pile est vidée afin que les ressources suivantes ne glissent plus sous le curseur, puis recompacte volontairement lors d'une nouvelle recherche ou à la réouverture ;
- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ;
- affiche chaque quête active comme un simple texte ombré en haut à gauche, sans panneau, bordure, titre, message vide ni barre de progression ;
- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ;
- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches.
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.196` et synchronise Packwiz avec l'index Prism/Modrinth ;
- conserve Red-Stoner data v3/protocole 7, Sanctuary data v11/protocole 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.221`
Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220.
- corrige la cause réelle du disque blanc ralenti et trop grave : la piste AAC YouTube reste décodée en stéréo native à 48 kHz, puis chaque paire gauche/droite est explicitement moyennée en un échantillon mono positionnel ;
- supprime la compensation OpenAL approximative à `4/3` et restitue la musique à sa hauteur et sa vitesse originales avec une hauteur `1.0` ;
- conserve un flux PCM mono signé 16 bits little-endian pour l'audio positionnel du jukebox, sans silence ajouté ni changement de durée ;
- ajoute un test déterministe du downmix stéréo et valide le lien signalé sur quatre secondes : 4 000 ms de source donnent 4 000 ms de PCM à 48 kHz ;
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
## `26.2.0-alpha.220`
Module modifié : **Red-Stoner `0.0.0-alpha.12`**. Les neuf autres modules conservent leur version issue de l'alpha.219.
- remplace l’écran propriétaire du terminal par un véritable menu de conteneur inspiré des coffres vanilla et composé avec le panneau dinventaire Sanctuary ;
- supprime le bouton de fermeture dédié : Échap et la touche dinventaire suivent désormais le comportement normal du jeu ;
- affiche et synchronise toutes les rangées dinventaire débloquées par Sanctuary, dune à six, y compris les deux rangées persistantes supplémentaires ;
- permet le retrait par clic gauche, le retrait unitaire par clic droit et le retrait vers linventaire par shift-clic ; un clic avec une pile portée ou un shift-clic depuis linventaire dépose dans le réseau ;
- conserve les 54 cases comme une vue agrégée non réorganisable et affiche les quantités exactes sans créer de stockage virtuel ;
- dépose dans le premier coffre ou baril disponible selon lordre déterministe de découverte, remplit dabord ses piles compatibles puis ses cases vides et ne passe au stockage suivant quen cas de besoin ;
- supprime les anciens payloads spécifiques au terminal au profit de la synchronisation native des menus, tout en gardant la distance, la topologie et les transferts sous autorité serveur ;
- conserve les données Red-Stoner v3 et les sauvegardes existantes ; le protocole réseau Red-Stoner passe de 6 à 7, sans nouveau monde ni réinitialisation.
## `26.2.0-alpha.219`
Modules modifiés : **Sanctuary `0.0.0-alpha.122`**, **I Like To Move It `0.0.0-alpha.16`** et **Ambiance `0.0.0-alpha.21`**. Les sept autres modules conservent leur version issue de l'alpha.218.
+8 -2
View File
@@ -22,15 +22,21 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
Version courante du pack distribué : **`26.2.0-alpha.219`**. Cette release distribue Sanctuary **`0.0.0-alpha.122`**, I Like To Move It **`0.0.0-alpha.16`** et Ambiance **`0.0.0-alpha.21`** ; les sept autres modules conservent leur version issue de l'alpha.218.
Version courante du pack distribué : **`26.2.0-alpha.222`**. Cette release distribue Red-Stoner **`0.0.0-alpha.13`** et Sanctuary **`0.0.0-alpha.124`** ; les huit autres modules conservent leur version issue de l'alpha.221.
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
Lalpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions dIDs ditems et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
Lalpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
Lalpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées dinventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans lordre de découverte avant de passer au suivant.
Lalpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
Lalpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants ditems restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
Lalpha.217 corrige définitivement la hauteur trop grave du disque blanc : la cadence AAC mesurée sur la vidéo signalée est compensée par un facteur OpenAL exact de 4/3, maintenu pendant toute la lecture.
Lalpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par lalpha.221, qui corrige directement la conversion stéréo vers mono.
Lalpha.216 corrige la lecture lente et dégradée du disque blanc : les quatre tampons PCM initiaux de Minecraft sont maintenant remplis continûment à 48 kHz, avec une vitesse OpenAL normale, au lieu d’être majoritairement complétés par du silence. Les packs Modrinth, Prism et Packwiz sont mis à jour sans exiger de nouveau monde ni réinitialiser les sauvegardes.
+6 -2
View File
@@ -16,8 +16,12 @@ jukebox vanilla.
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
trente minutes, et limite les lectures simultanées à quatre ;
- la lecture utilise le canal `RECORDS`, un flux PCM mono positionnel et une atténuation à
64 blocs. Elle sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se ferme.
- la source AAC stéréo est décodée à 48 kHz (48 000 Hz) puis convertie explicitement en PCM mono 16 bits
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
vitesse et la hauteur originales tout en restant positionnelle ;
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
64 blocs. La lecture sarrête lorsque le disque est éjecté, le chunk disparaît ou la session se
ferme.
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
+8 -5
View File
@@ -102,7 +102,7 @@ tasks.named("test") {
tasks.register("verifyAmbiance") {
group = "verification"
description = "Checks Ambiance alpha.21, including corrected white-disc pitch and Alpha textures."
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
inputs.files(fileTree("src/main/java"))
inputs.files(fileTree("src/main/resources"))
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java"))
@@ -161,7 +161,7 @@ tasks.register("verifyAmbiance") {
def whiteDiscPlaybackSmoke = file(
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
if (project.version.toString() != "0.0.0-alpha.21"
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.source?.pack_version != "26.2.0-alpha.125"
|| migration.target?.pack_version != "26.2.0-alpha.126"
|| migration.target?.modules?.ambiance != "0.0.0-alpha.1"
@@ -364,15 +364,18 @@ tasks.register("verifyAmbiance") {
|| !javaText.contains('tickPendingStarts(client)')
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|| !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L')
|| !javaText.contains('new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE')
|| !javaText.contains('new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|| !javaText.contains('initialWaitAttempted')
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 4.0F / 3.0F')
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|| !javaText.contains('SOURCE_CHANNELS = 2')
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|| whiteDiscAudioPlayer.contains('.release()')
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
@@ -472,7 +475,7 @@ tasks.register("verifyAlphaTextures") {
include "**/*golden*days*.zip", "**/*golden*days*.jar"
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
}.files
if (project.version.toString() != "0.0.0-alpha.21"
if (project.version.toString() != "0.0.0-alpha.22"
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|| migration.alpha_textures?.client_only != true
|| migration.alpha_textures?.bundled_assets != false
@@ -9,9 +9,13 @@ import java.util.concurrent.TimeoutException;
import javax.sound.sampled.AudioFormat;
import net.minecraft.client.sounds.AudioStream;
/** Adapts bounded mono PCM frames from Lavaplayer to Minecraft's streaming OpenAL channel. */
/** Downmixes bounded stereo PCM frames from Lavaplayer for Minecraft's mono OpenAL stream. */
final class LavaplayerAudioStream implements AudioStream {
static final int SAMPLE_RATE = 48_000;
static final int SOURCE_CHANNELS = 2;
static final int FRAME_SAMPLES = 960;
private static final int BYTES_PER_SAMPLE = Short.BYTES;
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
private static final AudioFormat FORMAT =
@@ -65,7 +69,7 @@ final class LavaplayerAudioStream implements AudioStream {
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
lastFrameTimecode = frame.getTimecode();
decodedFrames++;
pending = frame.getData();
pending = downmixStereoFrame(frame.getData());
pendingOffset = 0;
}
if (output.position() == 0 && ended) return null;
@@ -73,6 +77,27 @@ final class LavaplayerAudioStream implements AudioStream {
return output;
}
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
}
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
for (int sourceOffset = 0, targetOffset = 0;
sourceOffset < stereo.length;
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
short left = littleEndianSample(stereo, sourceOffset);
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
mono[targetOffset] = (byte) mixed;
mono[targetOffset + 1] = (byte) (mixed >>> 8);
}
return mono;
}
private static short littleEndianSample(byte[] pcm, int offset) {
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
}
private AudioFrame nextFrame() throws IOException {
try {
long waitMillis = initialWaitAttempted
@@ -36,8 +36,8 @@ public final class WhiteDiscAudioPlayer {
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
private static final float ATTENUATION_DISTANCE = 64.0F;
/** Compensates Lavaplayer's low AAC cadence before PCM reaches OpenAL. */
static final float PLAYBACK_PITCH = 4.0F / 3.0F;
/** OpenAL must preserve the decoded music's native speed and pitch. */
static final float PLAYBACK_PITCH = 1.0F;
private static final DefaultAudioPlayerManager MANAGER = createManager();
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
private static final Map<Long, Long> REQUESTS = new HashMap<>();
@@ -237,7 +237,8 @@ public final class WhiteDiscAudioPlayer {
private static DefaultAudioPlayerManager createManager() {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.setFrameBufferDuration(5_000);
manager.setItemLoaderThreadPoolSize(2);
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
@@ -7,7 +7,6 @@ import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
import java.lang.reflect.Proxy;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -15,22 +14,27 @@ import java.util.concurrent.atomic.AtomicInteger;
/** Deterministic check for Minecraft's four one-second streaming buffers. */
public final class LavaplayerAudioStreamSmoke {
private static final int FRAME_SAMPLES = 960;
private static final int FRAME_BYTES = FRAME_SAMPLES * 2;
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
private LavaplayerAudioStreamSmoke() {
}
public static void main(String[] args) throws Exception {
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 4.0F / 3.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc AAC pitch compensation changed");
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
throw new IllegalStateException("The white-disc playback pitch is not native");
}
Queue<AudioFrame> frames = new ArrayDeque<>();
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
1, LavaplayerAudioStream.SAMPLE_RATE, FRAME_SAMPLES, false);
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
FRAME_SAMPLES, false);
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
byte[] data = new byte[FRAME_BYTES];
Arrays.fill(data, (byte) (index + 1));
byte[] data = new byte[SOURCE_FRAME_BYTES];
for (int offset = 0; offset < data.length; offset += 4) {
putLittleEndian(data, offset, (short) (1_000 + index));
putLittleEndian(data, offset + 2, (short) (3_000 + index));
}
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
}
@@ -63,9 +67,14 @@ public final class LavaplayerAudioStreamSmoke {
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
}
while (pcm.hasRemaining()) {
if (pcm.get() == 0) {
throw new IllegalStateException("The stream padded available PCM with silence");
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
short expected = (short) (2_000 + frame);
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
int low = Byte.toUnsignedInt(pcm.get());
short actual = (short) (low | pcm.get() << 8);
if (actual != expected) {
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
}
}
}
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
@@ -80,4 +89,9 @@ public final class LavaplayerAudioStreamSmoke {
throw new IllegalStateException("Closing the stream did not destroy its player");
}
}
private static void putLittleEndian(byte[] data, int offset, short sample) {
data[offset] = (byte) sample;
data[offset + 1] = (byte) (sample >>> 8);
}
}
@@ -23,7 +23,8 @@ public final class WhiteDiscPlaybackSmoke {
public static void main(String[] args) throws Exception {
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
manager.getConfiguration().setOutputFormat(
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
try {
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
+39 -2
View File
@@ -1647,6 +1647,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort nest pas actif.",
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif pour en braquer un autre.",
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
@@ -1671,6 +1672,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
"message.anotherworld.safe_vault.raid_requires_vault": "Чтобы ограбить чужой сейф, у вас должен быть собственный активный сейф.",
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
@@ -1695,6 +1697,7 @@ tasks.register("generateAnotherWorldResources") {
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
"message.anotherworld.safe_vault.raid_requires_vault": "You must own an active safe before raiding another one.",
"message.anotherworld.safe_vault.empty": "This safe is empty.",
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
@@ -1818,6 +1821,7 @@ tasks.register("verifyAnotherWorldCatalog") {
description = "Checks the Another World alpha.51 catalog, player vault and climate-aware biome mixer."
dependsOn tasks.named("generateAnotherWorldResources")
inputs.file(file("src/main/resources/data/anotherworld/sanctuary_biome_catalog.json"))
inputs.file(file("src/main/resources/data/minecraft/tags/item/swords.json"))
inputs.file(rootProject.file("sanctuary/src/main/resources/data/sanctuary/worldgen/noise_settings/sanctuary.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.90-mossy-forest-hover-to-alpha.91.json"))
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.100-mossy-forest-lush-to-alpha.101.json"))
@@ -1953,7 +1957,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.55"
if (project.version.toString() != "0.0.0-alpha.58"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3231,6 +3235,10 @@ tasks.register("verifyAnotherWorldCatalog") {
["anotherworld:deepslate_silver_ore", "anotherworld:deepslate_titanium_ore"].each { requiredOre ->
if (!diamondToolValues.contains(requiredOre)) throw new GradleException("Diamond pickaxe requirement missing for ${requiredOre}")
}
def swordTag = readJson("data/minecraft/tags/item/swords.json")
if (swordTag.replace != false || !swordTag.values.contains("anotherworld:titanium_sword")) {
throw new GradleException("Titanium sword must participate in the vanilla sword contract")
}
def expectedColoredStairs = colors.collect { "anotherworld:${it}_brick_stairs".toString() }
def expectedColoredSlabs = colors.collect { "anotherworld:${it}_brick_slab".toString() }
["block", "item"].each { registry ->
@@ -3260,6 +3268,35 @@ tasks.named("check") {
dependsOn tasks.named("vaultRulesSmoke")
}
tasks.register("verifyTitaniumShears") {
group = "verification"
description = "Checks that titanium shears reproduce vanilla sheep shearing despite the hard-coded vanilla item check."
inputs.files(
file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumShearsItem.java"),
file("src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java")
)
doLast {
def shears = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/item/TitaniumShearsItem.java").text
def registry = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/registry/AnotherWorldItems.java").text
if (!registry.contains("TitaniumShearsItem::new")
|| !shears.contains("class TitaniumShearsItem extends ShearsItem")
|| !shears.contains("entity instanceof Sheep sheep")
|| !shears.contains("sheep.readyForShearing()")
|| !shears.contains("sheep.shear(level, SoundSource.PLAYERS, stack)")
|| !shears.contains("sheep.gameEvent(GameEvent.SHEAR, player)")
|| !shears.contains("stack.hurtAndBreak(1, player, hand.asEquipmentSlot())")
|| !shears.contains("return InteractionResult.SUCCESS_SERVER")) {
throw new GradleException("Titanium shears must reproduce the vanilla sheep shearing contract")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyTitaniumShears")
}
tasks.register("verifyClimateBiomeMixer") {
group = "verification"
description = "Checks the alpha.189 climate-coordinate biome classification and vanilla-majority shares."
@@ -3270,7 +3307,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.55"
if (project.version.toString() != "0.0.0-alpha.58"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -0,0 +1,38 @@
package fr.koka99cab.sanctuary26.anotherworld.item;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.animal.sheep.Sheep;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ShearsItem;
import net.minecraft.world.level.gameevent.GameEvent;
/** Titanium shears with the vanilla sheep interaction that Minecraft reserves for its own item id. */
public final class TitaniumShearsItem extends ShearsItem {
public TitaniumShearsItem(Properties properties) {
super(properties);
}
@Override
public InteractionResult interactLivingEntity(ItemStack stack, Player player, LivingEntity entity,
InteractionHand hand) {
if (!(entity instanceof Sheep sheep)) {
return super.interactLivingEntity(stack, player, entity, hand);
}
if (!(sheep.level() instanceof ServerLevel level)) {
return InteractionResult.CONSUME;
}
if (!sheep.readyForShearing()) {
return InteractionResult.CONSUME;
}
sheep.shear(level, SoundSource.PLAYERS, stack);
sheep.gameEvent(GameEvent.SHEAR, player);
stack.hurtAndBreak(1, player, hand.asEquipmentSlot());
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -1,9 +1,10 @@
package fr.koka99cab.sanctuary26.anotherworld.registry;
import fr.koka99cab.sanctuary26.anotherworld.AnotherWorldMod;
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumEquipment;
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumDrillItem;
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumEquipment;
import fr.koka99cab.sanctuary26.anotherworld.item.TitaniumShearsItem;
import fr.koka99cab.sanctuary26.anotherworld.item.WeatherCompassItem;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -95,7 +96,7 @@ public final class AnotherWorldItems {
);
public static final Item TITANIUM_SHEARS = register(
"titanium_shears",
ShearsItem::new,
TitaniumShearsItem::new,
TitaniumEquipment.uniqueProperties()
.durability(238)
.component(DataComponents.TOOL, ShearsItem.createToolProperties())
@@ -113,6 +113,12 @@ public final class VaultData extends SavedData {
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
}
public boolean hasRegisteredVault(UUID owner) {
if (owner == null) return false;
String ownerId = owner.toString();
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
}
public void unregisterNear(ServerLevel level, BlockPos member) {
String dimension = dimension(level);
boolean changed = vaults.removeIf(address -> {
@@ -163,7 +169,7 @@ public final class VaultData extends SavedData {
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
public long transferRaid(UUID owner, UUID attacker) {
if (owner == null || attacker == null || owner.equals(attacker)) return 0L;
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
Account source = accounts.get(owner);
if (source == null) return 0L;
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
@@ -180,6 +186,10 @@ public final class VaultData extends SavedData {
return total;
}
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
}
public long protectionRemaining(UUID owner, long gameTime) {
Account account = accounts.get(owner);
return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime);
@@ -48,6 +48,10 @@ public final class VaultDrillingService {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
return false;
}
if (!data.hasRegisteredVault(attacker.getUUID())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
return false;
}
if (!data.hasContents(formed.owner())) {
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
return false;
@@ -104,6 +108,13 @@ public final class VaultDrillingService {
attacker.stopUsingItem();
return;
}
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
cancel(attacker, false);
attacker.sendOverlayMessage(Component.translatable(
"message.anotherworld.safe_vault.raid_requires_vault"));
attacker.stopUsingItem();
return;
}
int elapsed = session.elapsedTicks() + 1;
Session progressed = session.withElapsed(elapsed);
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"anotherworld:titanium_sword"
]
}
@@ -1,5 +1,7 @@
package fr.koka99cab.sanctuary26.anotherworld.vault;
import java.util.UUID;
public final class VaultRulesSmoke {
private VaultRulesSmoke() {
}
@@ -55,6 +57,14 @@ public final class VaultRulesSmoke {
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
"64-bit balance raid without overflow");
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
"an attacker without a registered vault cannot receive raid loot");
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
"an attacker with a registered vault can receive raid loot");
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
"a vault owner cannot receive loot from their own vault");
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
"balance saturation without overflow");
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
+613 -218
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -12,11 +12,11 @@ fabric_api_version=0.158.0+26.2
# Standby modules stay at zero until their migration really begins.
mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.21
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.11
redstoner_version=0.0.0-alpha.13
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.55
anotherworld_version=0.0.0-alpha.58
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.36
itsalive_lifecycle=active
@@ -24,11 +24,11 @@ ouch_version=0.0.0-alpha.10
ouch_lifecycle=active
masterkey_version=0.0.0-alpha.8
masterkey_lifecycle=active
iliketomoveit_version=0.0.0-alpha.16
iliketomoveit_version=0.0.0-alpha.17
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.25
onlyfun_version=0.0.0-alpha.26
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.122
sanctuary_version=0.0.0-alpha.126
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.219
pack_version=26.2.0-alpha.222
maven_group=fr.koka99cab.sanctuary26
+11 -6
View File
@@ -250,11 +250,13 @@ tasks.register("verifyPortals") {
doLast {
def javaText = fileTree("src/main/java").matching { include "**/*.java" }.files*.text.join("\n")
def mixins = new JsonSlurper().parse(file("src/main/resources/iliketomoveit.mixins.json"))
def requiredJava = [
'class PortalDefinitionReloadListener', 'FileToIdConverter.json("sanctuary_portal")',
'class PortalBlockEntity', 'class PortalSavedData', 'SAVE_KEY = "portal_links"',
'DATA_VERSION = 1', 'MAX_ENDPOINTS = 8192', 'UUID.randomUUID()',
'class PortalIgnitionHandler', 'PortalDefinitions.matching', 'class PortalTeleporter',
'class PortalIgnitionHandler', 'PortalDefinitions.matching', 'matchingFrame',
'tryIgniteFromFireball', 'class PortalTeleporter',
'data.link(source, target)', 'data.deactivate', 'destination_unavailable',
'LEGACY_ALPHA_PORTAL', 'LEGACY_CAVERNS_PORTAL', 'LEGACY_OVERWORLD_PORTAL',
'LEGACY_SHEEP_LAND_PORTAL', 'LEGACY_SKELETON_WASTE_PORTAL',
@@ -265,6 +267,9 @@ tasks.register("verifyPortals") {
]
def missingJava = requiredJava.findAll { !javaText.contains(it) }
if (!missingJava.isEmpty()) throw new GradleException("Incomplete portal runtime contract: ${missingJava}")
if (!mixins.mixins.containsAll(["LargeFireballMixin", "SmallFireballMixin"])) {
throw new GradleException("Player-owned fireball portal ignition mixins are missing")
}
def expectedDestinations = [
overworld: "sanctuary:overworld", caverns: "sanctuary:caverns", alpha: "sanctuary:alpha",
@@ -313,7 +318,7 @@ tasks.register("verifyPortals") {
}
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|| atlasCavernsMigration.portals?.active_routes != [
@@ -349,7 +354,7 @@ tasks.register("verifySilverAircraft") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def migration = new JsonSlurper().parse(rootProject.file(
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
@@ -378,7 +383,7 @@ tasks.register("verifyGrapplePrediction") {
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
@@ -410,7 +415,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
def teleportation = file(
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
@@ -444,7 +449,7 @@ tasks.register("verifyAlphaPortal") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.16"
if (project.version.toString() != "0.0.0-alpha.17"
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|| route.destination_dimension != "sanctuary:alpha"
|| route.destination_resolver != "fixed"
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.portal.PortalIgnitionHandler;
import net.minecraft.world.entity.projectile.hurtingprojectile.LargeFireball;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
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;
/** Lets a player-owned ghast fireball ignite a valid custom frame without destroying it. */
@Mixin(LargeFireball.class)
abstract class LargeFireballMixin {
@Inject(method = "onHit", at = @At("HEAD"), cancellable = true)
private void iliketomoveit$ignitePortal(HitResult hit, CallbackInfo ci) {
if (hit instanceof BlockHitResult blockHit
&& PortalIgnitionHandler.tryIgniteFromFireball((LargeFireball) (Object) this, blockHit)) {
((LargeFireball) (Object) this).discard();
ci.cancel();
}
}
}
@@ -0,0 +1,20 @@
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
import fr.koka99cab.sanctuary26.iliketomoveit.portal.PortalIgnitionHandler;
import net.minecraft.world.entity.projectile.hurtingprojectile.SmallFireball;
import net.minecraft.world.phys.BlockHitResult;
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;
/** Uses a player-owned blaze fireball as portal ignition instead of placing ordinary fire. */
@Mixin(SmallFireball.class)
abstract class SmallFireballMixin {
@Inject(method = "onHitBlock", at = @At("HEAD"), cancellable = true)
private void iliketomoveit$ignitePortal(BlockHitResult hit, CallbackInfo ci) {
if (PortalIgnitionHandler.tryIgniteFromFireball((SmallFireball) (Object) this, hit)) {
ci.cancel();
}
}
}
@@ -45,11 +45,22 @@ public final class PortalDefinitions {
public static List<PortalDefinition> matching(ItemStack activation, BlockState frame) {
return definitions.values().stream()
.filter(definition -> definition.matchesActivation(activation) && definition.matchesFrame(frame))
.sorted(Comparator.comparingInt(PortalDefinition::priority).reversed()
.thenComparing(definition -> definition.id().toString()))
.sorted(priorityOrder())
.toList();
}
public static List<PortalDefinition> matchingFrame(BlockState frame) {
return definitions.values().stream()
.filter(definition -> definition.matchesFrame(frame))
.sorted(priorityOrder())
.toList();
}
private static Comparator<PortalDefinition> priorityOrder() {
return Comparator.comparingInt(PortalDefinition::priority).reversed()
.thenComparing(definition -> definition.id().toString());
}
static void replace(Map<Identifier, PortalDefinition> replacement) {
List<Identifier> missing = new ArrayList<>(REQUIRED_ROUTES);
missing.removeAll(replacement.keySet());
@@ -9,7 +9,10 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.BlockHitResult;
/** Ignites the highest-priority matching data-driven route. */
public final class PortalIgnitionHandler {
@@ -21,36 +24,59 @@ public final class PortalIgnitionHandler {
ItemStack stack = player.getItemInHand(hand);
BlockPos clicked = hit.getBlockPos();
BlockPos interior = clicked.relative(hit.getDirection());
PortalDefinition selected = null;
PortalFrame selectedFrame = null;
for (PortalDefinition definition : PortalDefinitions.matching(stack, level.getBlockState(clicked))) {
Optional<PortalFrame> frame = PortalFrame.findForIgnition(level, interior, definition::matchesFrame);
if (frame.isPresent()) {
selected = definition;
selectedFrame = frame.get();
break;
}
}
if (selected == null || selectedFrame == null) return InteractionResult.PASS;
Optional<IgnitionTarget> target = findTarget(level, interior,
PortalDefinitions.matching(stack, level.getBlockState(clicked)));
if (target.isEmpty()) return InteractionResult.PASS;
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!level.mayInteract(player, clicked) || !level.mayInteract(player, interior)) {
return InteractionResult.FAIL;
}
ServerLevel serverLevel = (ServerLevel) level;
if (serverLevel.getServer().getLevel(
selected.targetDimension(serverLevel.getServer(), serverLevel.dimension())) == null) {
player.sendOverlayMessage(Component.translatable(
"message.iliketomoveit.portal.destination_unavailable"));
if (!ignite(serverLevel, player, clicked, interior, target.get(), SoundEvents.FLINTANDSTEEL_USE)) {
return InteractionResult.FAIL;
}
PortalEndpoint endpoint = PortalSavedData.get(serverLevel.getServer())
.register(serverLevel, selectedFrame, selected.id());
selectedFrame.fill(serverLevel, selected, endpoint, ILikeToMoveItBlocks.PORTAL);
serverLevel.playSound(player, interior, SoundEvents.FLINTANDSTEEL_USE,
SoundSource.BLOCKS, 1.0F, serverLevel.getRandom().nextFloat() * 0.4F + 0.8F);
stack.hurtAndBreak(1, player, hand);
return InteractionResult.SUCCESS;
});
}
/** Handles player-owned fireballs without coupling Sanctuary companion code to portal internals. */
public static boolean tryIgniteFromFireball(Fireball fireball, BlockHitResult hit) {
if (!(fireball.level() instanceof ServerLevel level) || !(fireball.getOwner() instanceof Player player)) {
return false;
}
BlockPos clicked = hit.getBlockPos();
BlockPos interior = clicked.relative(hit.getDirection());
Optional<IgnitionTarget> target = findTarget(level, interior,
PortalDefinitions.matchingFrame(level.getBlockState(clicked)));
if (target.isEmpty()) return false;
ignite(level, player, clicked, interior, target.get(), SoundEvents.FIRECHARGE_USE);
return true;
}
private static Optional<IgnitionTarget> findTarget(net.minecraft.world.level.LevelReader level,
BlockPos interior, Iterable<PortalDefinition> definitions) {
for (PortalDefinition definition : definitions) {
Optional<PortalFrame> frame = PortalFrame.findForIgnition(level, interior, definition::matchesFrame);
if (frame.isPresent()) return Optional.of(new IgnitionTarget(definition, frame.get()));
}
return Optional.empty();
}
private static boolean ignite(ServerLevel level, Player player, BlockPos clicked, BlockPos interior,
IgnitionTarget target, net.minecraft.sounds.SoundEvent sound) {
if (!level.mayInteract(player, clicked) || !level.mayInteract(player, interior)) return false;
PortalDefinition definition = target.definition();
if (level.getServer().getLevel(
definition.targetDimension(level.getServer(), level.dimension())) == null) {
player.sendOverlayMessage(Component.translatable(
"message.iliketomoveit.portal.destination_unavailable"));
return false;
}
PortalEndpoint endpoint = PortalSavedData.get(level.getServer())
.register(level, target.frame(), definition.id());
target.frame().fill(level, definition, endpoint, ILikeToMoveItBlocks.PORTAL);
level.playSound(player, interior, sound, SoundSource.BLOCKS, 1.0F,
level.getRandom().nextFloat() * 0.4F + 0.8F);
return true;
}
private record IgnitionTarget(PortalDefinition definition, PortalFrame frame) {}
}
@@ -3,7 +3,9 @@
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"HappyGhastMixin"
"HappyGhastMixin",
"LargeFireballMixin",
"SmallFireballMixin"
],
"injectors": {
"defaultRequire": 1
@@ -1,10 +1,18 @@
package fr.koka99cab.sanctuary26.iliketomoveit.portal;
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
import java.util.Map;
import java.util.Set;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** Executable cross-module contract for the active mossy Alpha portal. */
public final class AlphaPortalContractSmoke {
@@ -19,6 +27,7 @@ public final class AlphaPortalContractSmoke {
require(Set.copyOf(PortalDefinitions.ACTIVE_ROUTES).equals(Set.of(
PortalDefinitions.OVERWORLD, PortalDefinitions.CAVERNS, PortalDefinitions.ALPHA)),
"Exactly the Overworld, Caverns and Alpha routes must be active");
verifyFrameOnlySelection();
for (long seed : new long[] { 0L, 1L, 46L }) {
BlockPos arrival = SanctuaryAlphaWorld.portalArrival(seed);
@@ -31,6 +40,25 @@ public final class AlphaPortalContractSmoke {
}
}
private static void verifyFrameOnlySelection() {
PortalDefinition overworld = route(PortalDefinitions.OVERWORLD, Blocks.DIRT,
SanctuaryDimensions.OVERWORLD, 100);
PortalDefinition caverns = route(PortalDefinitions.CAVERNS, Blocks.STONE,
SanctuaryDimensions.CAVERNS, 100);
PortalDefinition alpha = route(PortalDefinitions.ALPHA, Blocks.MOSSY_COBBLESTONE,
SanctuaryDimensions.ALPHA, 100);
PortalDefinitions.replace(Map.of(overworld.id(), overworld, caverns.id(), caverns, alpha.id(), alpha));
require(PortalDefinitions.matchingFrame(Blocks.STONE.defaultBlockState()).equals(java.util.List.of(caverns)),
"Fireball ignition must select a route from its frame without an activation item");
}
private static PortalDefinition route(Identifier id, Block frame, ResourceKey<Level> destination,
int priority) {
return new PortalDefinition(id, Set.of(frame), frame, Set.of(Items.FLINT_AND_STEEL),
Level.OVERWORLD, destination, PortalDestinationResolver.FIXED, 1.0D,
PortalArrival.SURFACE, 0, priority);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
@@ -21,8 +21,9 @@ public final class OnlyFunItemPolicy {
"budding_amethyst", "petrified_oak_slab", "suspicious_sand", "suspicious_gravel",
"air", "cave_air", "void_air", "fire", "soul_fire", "water", "lava",
"nether_portal", "end_portal", "end_gateway", "frosted_ice", "farmland", "dirt_path",
"dragon_egg", "filled_map", "written_book", "enchanted_book"
"dragon_egg", "filled_map", "written_book"
);
private static final Set<String> SHOP_ONLY_BLOCKS = Set.of("enchanted_book");
private static final Set<String> VANILLA_SHOP_GAMEPLAY_BYPASSES = Set.of(
"baked_potato", "beetroot_soup", "bread", "cake",
"cooked_beef", "cooked_chicken", "cooked_cod", "cooked_mutton",
@@ -45,7 +46,8 @@ public final class OnlyFunItemPolicy {
if (namespace.equals("minecraft") && VANILLA_SHOP_GAMEPLAY_BYPASSES.contains(path)) return true;
if (namespace.equals("anotherworld") && isProtectedAnotherWorldItem(path)) return true;
if (isStorageTerminal(namespace, path)) return true;
if (TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
if (SHOP_ONLY_BLOCKS.contains(path) || TECHNICAL_OR_REWARD_BLOCKS.contains(path)
|| path.startsWith("infested_")
|| path.contains("command_block")) return true;
if (path.contains("lootbox") || path.contains("loot_box")) return true;
if (namespace.equals("onlyfun") && path.equals("lucky_block")) return true;
@@ -19,6 +19,8 @@ public final class OnlyFunGeneratorSmoke {
OnlyFunShopCatalogSmoke.verify();
CobbleDungeonPlacementSmoke.verify();
OnlyFunGenerator.initialize();
require(OnlyFunGenerator.outcomes().stream().anyMatch(outcome -> outcome.id().equals("enchanted_book")),
"The enchanted-book lootbox outcome disappeared");
require(OnlyFunGenerator.ANOMALY_DENOMINATOR == 256, "Anomaly odds changed from 1/256");
require(OnlyFunGenerator.PITY_START == 24, "Pity threshold changed");
require(OnlyFunGenerator.outcomes().stream().map(OnlyFunOutcome::id).distinct().count()
@@ -10,6 +10,7 @@ import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.SpawnEggItem;
/** Registry-backed smoke: every eligible vanilla block and spawn egg appears exactly once. */
@@ -88,6 +89,13 @@ public final class OnlyFunShopCatalogSmoke {
"Storage Terminal reached lootbox rewards");
require(find(all, storageTerminal.toString()) == null,
"Storage Terminal reached the generated Shop catalogue");
Identifier enchantedBook = Identifier.parse("minecraft:enchanted_book");
require(OnlyFunItemPolicy.excludedFromShop(enchantedBook),
"Enchanted books must remain excluded from the Shop");
require(!OnlyFunItemPolicy.excludedFromRewards(Items.ENCHANTED_BOOK),
"Enchanted-book lootbox rewards must be deliverable");
require(find(all, enchantedBook.toString()) == null,
"Enchanted books reached the generated Shop catalogue");
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_carpet"),
"Magic Carpet must remain boss-gated");
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_cloud"),
@@ -0,0 +1,54 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.219",
"modules": {
"redstoner": "0.0.0-alpha.11"
}
},
"target": {
"pack_version": "26.2.0-alpha.220",
"modules": {
"redstoner": "0.0.0-alpha.12"
}
},
"storage_terminal_menu": {
"owner": "redstoner",
"id": "redstoner:storage_terminal",
"menu_type": "redstoner:storage_terminal",
"native_container_menu": true,
"dedicated_close_button": false,
"terminal_slots": 54,
"terminal_slots_reorderable": false,
"quantity_data_words_per_slot": 2,
"minimum_player_inventory_rows": 1,
"maximum_player_inventory_rows": 6,
"inventory_rows_authority": "sanctuary_progression",
"left_click_with_empty_cursor": "take_stack",
"right_click_with_empty_cursor": "take_one",
"click_with_carried_stack": "deposit",
"shift_click_from_terminal": "take_to_unlocked_inventory",
"shift_click_from_player": "deposit",
"deposit_order": "first_discovered_container",
"per_container_order": ["compatible_stacks", "empty_slots"],
"spill_to_next_container_when_required": true,
"strict_item_component_aggregation": true,
"server_authoritative_transfers": true,
"vanilla_menu_synchronization": true,
"legacy_terminal_payloads_removed": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"redstoner_data_version": 3,
"redstoner_source_network_protocol": 6,
"redstoner_target_network_protocol": 7
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,46 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.220",
"modules": {
"ambiance": "0.0.0-alpha.21"
}
},
"target": {
"pack_version": "26.2.0-alpha.221",
"modules": {
"ambiance": "0.0.0-alpha.22"
}
},
"white_disc_native_rate": {
"owner": "ambiance",
"reference_video_id": "Ue5ZBe-GzSM",
"source_format": "MP4_AAC_LC",
"decoded_sample_rate_hz": 48000,
"decoded_source_channels": 2,
"openal_output_channels": 1,
"sample_size_bits": 16,
"byte_order": "little_endian",
"stereo_to_mono": "average_left_right_per_sample",
"openal_pitch": 1.0,
"decoded_timeline_millis": 4000,
"openal_timeline_millis": 4000,
"padded_silence_bytes": 0,
"positional_audio_preserved": true,
"previous_pitch_compensation_removed": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_changed": false,
"ambiance_data_version": 2,
"ambiance_network_protocol": 6
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,60 @@
{
"schema_version": 1,
"source": {
"pack_version": "26.2.0-alpha.221",
"modules": {
"redstoner": "0.0.0-alpha.12",
"sanctuary": "0.0.0-alpha.122"
}
},
"target": {
"pack_version": "26.2.0-alpha.222",
"modules": {
"redstoner": "0.0.0-alpha.13",
"sanctuary": "0.0.0-alpha.124"
}
},
"storage_terminal_search": {
"owner": "redstoner",
"maximum_search_length": 32,
"searches_display_name_item_id_and_translation": true,
"accent_and_unicode_folding": true,
"compact_quantity_labels": true,
"exact_quantity_tooltip": true,
"empty_slots_remain_stable_until_new_search_or_reopen": true,
"server_authoritative_transfers": true
},
"quest_tracker": {
"owner": "sanctuary",
"default_key": "J",
"maximum_entries": 8,
"accepted_quests_only": true,
"reserved_contracts_included": true,
"own_published_requests_excluded": true,
"transparent_text_only_hud": true,
"item_and_mob_id_suggestions": true,
"server_authoritative_snapshot": true
},
"compatibility": {
"new_world_required": false,
"save_reset_required": false,
"existing_saves_supported": true,
"registry_ids_changed": false,
"redstoner_data_version": 3,
"redstoner_network_protocol": 7,
"sanctuary_data_version": 11,
"sanctuary_network_protocol": 22
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"jei": {
"source": "30.28.0.193",
"target": "30.29.0.196",
"modrinth_version_id": "vvwsu2BR",
"packwiz_and_prism_synchronized": true
}
}
}
+14 -14
View File
@@ -13,16 +13,16 @@ file = "instance-icon.png"
hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.21.jar"
hash = "c9dfd6ed13d1fdd2277e62d9bd2faa18c008d621bbcf8ef605736eefc3378663"
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "a6b7d98971f411b202f5679aae05c1bc93754ee9a42d10dc903413b17ba97a63"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.55.jar"
hash = "0137f9a8109438c659c7a126ecde2e28982e063ee95f1991603a9ea81bbe7501"
hash = "cac322006ad9a724bb656b400cf47d84a18e76cd1a35f4bd13c112f443d89275"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "3a666f7b7bdb9cbf7181eff3e49455768ac76905a482577990fdc1eb018e78d7"
hash = "8d2566ceb838a486fe9d8539acd127446e51d7085dcbefa86ee3c1aea4630a6d"
[[files]]
file = "mods/fabric-api.pw.toml"
@@ -31,7 +31,7 @@ metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.16.jar"
hash = "f42ed4fe37cbf3422e3b2493db562e82da3f2ba04568cea6ae304862e64e54df"
hash = "94e5afefab9485c40a47342dc84f401fa126be40064832929601e781b3a03738"
[[files]]
file = "mods/iris.pw.toml"
@@ -40,32 +40,32 @@ metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.36.jar"
hash = "71c9d893fd0862610f203b854d1af19e0e6e4b2bc9e06f6da38d057e23a54ebd"
hash = "94899dba383aff41c39f7f25ee1a6b7998aa1e9889e7b9e9039a984e0bc0882f"
[[files]]
file = "mods/jei.pw.toml"
hash = "a213ae237f9b3934bdcd0d282119d4166d0ccbb7c7223daa8d7ed956c69a0e3f"
hash = "115d2239bc86ecdad88705dbc215d27ddb6a08d94f74fa538abb6a67a00bf71e"
metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.8.jar"
hash = "75ae764c2b86d2f4479f576d9a1cd8272c1979d754069277052ef11064fabbda"
hash = "102c3da5d201452be23ab84338e023c284f4770270ca945b0c63b6a996fb2922"
[[files]]
file = "mods/onlyfun-0.0.0-alpha.25.jar"
hash = "69cca044dd3cd3fbbd6d9d45706a502ec17689c040521524eac3aa20ab93bf5f"
hash = "2676072de7dbc9b27c4fd0e50e797832356fbc903e80c5bd9a5bd55c1a5aff1b"
[[files]]
file = "mods/ouch-0.0.0-alpha.10.jar"
hash = "c414a5b308796a797c6dce9b0337edfd06c536a18d1c746307f58e99e3a6be2c"
hash = "59815a0415c48c92acfc7c18bd2399780bab9b0b5eee69c2b35a66cb1474109b"
[[files]]
file = "mods/redstoner-0.0.0-alpha.11.jar"
hash = "753a7b4f8a26a836904099ed034dc36dcb371b87f5b67044940345fdadeb6b33"
file = "mods/redstoner-0.0.0-alpha.13.jar"
hash = "0bd3888b13c894aed806696a5e73e076c7ed985eff2c242f0df9ec64ff3fcb37"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.122.jar"
hash = "b4ab04b167c50d412af791ccadf838ebcb0443303e05864c9940e02ec3cc0d40"
file = "mods/sanctuary-0.0.0-alpha.124.jar"
hash = "39a3db3d1d9876ac5c7a7135a3b524c82508d0ee2e0c5c410c6515ff8931c6fb"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Just Enough Items (JEI)"
filename = "jei-26.2-fabric-30.28.0.193.jar"
filename = "jei-26.2-fabric-30.29.0.196.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/qaCQ7LGO/jei-26.2-fabric-30.28.0.193.jar"
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/vvwsu2BR/jei-26.2-fabric-30.29.0.196.jar"
hash-format = "sha512"
hash = "0869f4d5840a732a04bedc7684b40140f087f3d759066c206d9d1ec710007f7c9585e4f5ebb04f10e56bef1cf2eddc6978bb2b2c93541cff5fcf7e1609859468"
hash = "c62a3e264d4b0ec398b9aa717a4832eecb8663bbcec4483dc58a9999d5e4e55ca4f5bca285a0d4a003220c698c3e87c8a3294366e085e72545e4afe84aec92c7"
[update]
[update.modrinth]
mod-id = "u6dRKJwZ"
version = "qaCQ7LGO"
version = "vvwsu2BR"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.219"
version = "26.2.0-alpha.222"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "a694c1327286dca4369aa64dfea7decbc36fedbba77188e7d8d310c08365d415"
hash = "53b5bec1b113ab2b353e6a142e3dbc4617ea7d27003bcbb09b668938dd262ddd"
[versions]
fabric = "0.19.3"
+6 -6
View File
@@ -3,7 +3,7 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.219",
"versionId": "26.2.0-alpha.222",
"files": [
{
"path": "mods/fabric-api-0.158.0+26.2.jar",
@@ -21,19 +21,19 @@
"fileSize": 2535765
},
{
"path": "mods/jei-26.2-fabric-30.28.0.193.jar",
"path": "mods/jei-26.2-fabric-30.29.0.196.jar",
"hashes": {
"sha1": "494f9dc787d6c9cd9d32793da2784ab16834faf3",
"sha512": "0869f4d5840a732a04bedc7684b40140f087f3d759066c206d9d1ec710007f7c9585e4f5ebb04f10e56bef1cf2eddc6978bb2b2c93541cff5fcf7e1609859468"
"sha1": "c2f2c6b5822a560fe9872921b908ffc2f3297d00",
"sha512": "c62a3e264d4b0ec398b9aa717a4832eecb8663bbcec4483dc58a9999d5e4e55ca4f5bca285a0d4a003220c698c3e87c8a3294366e085e72545e4afe84aec92c7"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/qaCQ7LGO/jei-26.2-fabric-30.28.0.193.jar"
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/vvwsu2BR/jei-26.2-fabric-30.29.0.196.jar"
],
"fileSize": 1822836
"fileSize": 1838353
},
{
"path": "mods/voicechat-fabric-2.6.22+26.2.jar",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.219",
"pack_version": "26.2.0-alpha.222",
"release_channel": "alpha",
"minecraft_version": "26.2",
"fabric_loader_version": "0.19.3",
@@ -9,7 +9,7 @@
"project_id": "FgOqyAXd",
"project_slug": "sanctuary-koka99",
"project_url": "https://modrinth.com/modpack/sanctuary-koka99",
"primary_file": "sanctuary-26.2.0-alpha.219.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.222.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.redstoner;
import fr.koka99cab.sanctuary26.redstoner.api.RedStonerApi;
import fr.koka99cab.sanctuary26.redstoner.network.RedStonerNetworking;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -26,6 +27,7 @@ public final class RedStonerMod implements ModInitializer {
public void onInitialize() {
RedStonerApi.registerServerIdentityProvider(SanctuaryServerIdentity::get);
RedStonerRegistries.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur and Storage Terminal ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, RedStonerNetworking.PROTOCOL_VERSION);
@@ -1,7 +1,7 @@
package fr.koka99cab.sanctuary26.redstoner.block;
import com.mojang.serialization.MapCodec;
import fr.koka99cab.sanctuary26.redstoner.network.RedStonerNetworking;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalNetwork;
import java.util.Map;
import net.minecraft.core.BlockPos;
@@ -106,7 +106,7 @@ public final class StorageTerminalBlock extends Block {
private static InteractionResult open(Level level, BlockPos pos, Player player) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (player instanceof ServerPlayer serverPlayer) {
RedStonerNetworking.openStorageTerminal(serverPlayer, pos);
StorageTerminalMenu.open(serverPlayer, pos);
return InteractionResult.SUCCESS_SERVER;
}
return InteractionResult.CONSUME;
@@ -7,17 +7,19 @@ import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.StorageTerminalSnapshotPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
import net.minecraft.client.gui.screens.MenuScreens;
public final class RedStonerClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
BlockEntityRendererRegistry.register(RedStonerRegistries.DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
BlockEntityRendererRegistry.register(RedStonerRegistries.LEGACY_DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
MenuScreens.register(RedStonerMenus.STORAGE_TERMINAL, StorageTerminalScreen::new);
ClientPlayNetworking.registerGlobalReceiver(OpenComputerPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(new ComputerScreen(payload))));
ClientPlayNetworking.registerGlobalReceiver(OpenPersonalComputerPayload.TYPE, (payload, context) ->
@@ -28,15 +30,6 @@ public final class RedStonerClient implements ClientModInitializer {
context.client().gui.setScreen(new PersonalComputerScreen(payload));
}
}));
ClientPlayNetworking.registerGlobalReceiver(StorageTerminalSnapshotPayload.TYPE, (payload, context) ->
context.client().execute(() -> {
if (context.client().gui.screen() instanceof StorageTerminalScreen screen
&& screen.represents(payload)) {
screen.acceptSnapshot(payload);
} else {
context.client().gui.setScreen(new StorageTerminalScreen(payload));
}
}));
RedStonerMod.LOGGER.info("[{}] Client renderers and laboratory screens ready.", RedStonerMod.DISPLAY_NAME);
}
@@ -1,86 +1,111 @@
package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.network.StorageTerminalActionPayload;
import fr.koka99cab.sanctuary26.redstoner.network.StorageTerminalSnapshotPayload;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus;
import java.util.Locale;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class StorageTerminalScreen extends Screen {
private static final int PANEL_WIDTH = 204;
private static final int PANEL_HEIGHT = 236;
private static final int SLOT_SIZE = 18;
private static final int GRID_X = 8;
private static final int GRID_Y = 20;
private static final int PLAYER_GRID_Y = 152;
private static final int HOTBAR_Y = 210;
private static final int SCROLL_X = 176;
private static final int SCROLL_WIDTH = 8;
private static final int REFRESH_TICKS = 20;
private StorageTerminalSnapshotPayload snapshot;
/** Vanilla-style terminal surface composed with Sanctuary's dynamic player-inventory panel. */
public final class StorageTerminalScreen extends AbstractContainerScreen<StorageTerminalMenu> {
private static final Identifier TEXTURE = Identifier.withDefaultNamespace(
"textures/gui/container/generic_54.png");
private static final int TERMINAL_TOP = 18;
private static final int TERMINAL_HEIGHT = 108;
private static final int SCROLL_X = 171;
private static final int SCROLL_WIDTH = 4;
private int requestedPage;
private int refreshCooldown = REFRESH_TICKS;
private EditBox searchBox;
private String searchValue = "";
private String sentSearchValue = "";
public StorageTerminalScreen(StorageTerminalSnapshotPayload snapshot) {
super(Component.translatable("screen.redstoner.storage_terminal.title"));
this.snapshot = snapshot;
this.requestedPage = snapshot.page();
}
public boolean represents(StorageTerminalSnapshotPayload other) {
return snapshot.pos().equals(other.pos());
}
public void acceptSnapshot(StorageTerminalSnapshotPayload refreshed) {
if (!represents(refreshed)) return;
snapshot = refreshed;
requestedPage = refreshed.page();
refreshCooldown = REFRESH_TICKS;
public StorageTerminalScreen(StorageTerminalMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 222);
requestedPage = menu.page();
}
@Override
protected void init() {
addRenderableWidget(Button.builder(Component.literal("×"), button -> onClose())
.bounds(panelX() + PANEL_WIDTH - 22, panelY() + 4, 18, 14).build());
super.init();
titleLabelY = 6;
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 126, 14,
Component.translatable("screen.redstoner.storage_terminal.search")));
searchBox.setMaxLength(StorageTerminalRules.MAX_SEARCH_LENGTH);
searchBox.setHint(Component.translatable("screen.redstoner.storage_terminal.search"));
searchBox.setValue(searchValue);
searchBox.setResponder(this::searchChanged);
setInitialFocus(searchBox);
}
@Override
public void tick() {
if (--refreshCooldown <= 0) requestPage(requestedPage);
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
super.extractBackground(graphics, mouseX, mouseY, tickDelta);
graphics.blit(RenderPipelines.GUI_TEXTURED, TEXTURE, leftPos, topPos, 0.0F, 0.0F,
imageWidth, imageHeight, 256, 256);
extractScrollBar(graphics);
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
graphics.fill(0, 0, width, height, 0xCC101010);
int x = panelX();
int y = panelY();
graphics.fill(x + 3, y + 3, x + PANEL_WIDTH + 3, y + PANEL_HEIGHT + 3, 0xA0000000);
graphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, 0xFFC6C6C6);
graphics.outline(x, y, PANEL_WIDTH, PANEL_HEIGHT, 0xFF373737);
graphics.text(font, title, x + 8, y + 7, 0xFF202020, false);
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
Component pageLabel = Component.literal((menu.page() + 1) + "/" + menu.pageCount());
graphics.text(font, pageLabel, 168 - font.width(pageLabel), titleLabelY, 0xFF404040, false);
if (menu.status() != StorageTerminalStatus.READY) {
graphics.centeredText(font, menu.statusMessage(), imageWidth / 2, 68, 0xFFAA3333);
} else if (!searchValue.isBlank() && menu.totalEntries() == 0) {
graphics.centeredText(font, Component.translatable(
"screen.redstoner.storage_terminal.no_results"), imageWidth / 2, 68, 0xFF777777);
}
}
if (snapshot.status() == StorageTerminalStatus.READY) {
renderTerminalGrid(graphics, x, y, mouseX, mouseY);
} else {
graphics.centeredText(font, Component.translatable("screen.redstoner.storage_terminal.error."
+ snapshot.status().name().toLowerCase(Locale.ROOT)), x + 90, y + 70, 0xFFAA3333);
@Override
protected void extractSlot(GuiGraphicsExtractor graphics, Slot slot, int mouseX, int mouseY) {
super.extractSlot(graphics, slot, mouseX, mouseY);
if (slot.index < StorageTerminalMenu.TERMINAL_SLOT_COUNT && slot.hasItem()) {
int total = menu.totalAt(slot.index);
if (total > 1) {
String label = StorageTerminalRules.compactCount(total);
graphics.text(font, label, slot.x + 17 - font.width(label), slot.y + 9,
0xFFFFFFFF, true);
}
}
}
@Override
protected List<Component> getTooltipFromContainerItem(ItemStack stack) {
List<Component> tooltip = new ArrayList<>(super.getTooltipFromContainerItem(stack));
if (hoveredSlot != null && hoveredSlot.index < StorageTerminalMenu.TERMINAL_SLOT_COUNT) {
int total = menu.totalAt(hoveredSlot.index);
if (total > 0) tooltip.add(Component.literal("×" + total).withStyle(ChatFormatting.GRAY));
}
return tooltip;
}
@Override
protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
if (isHovering(SCROLL_X - 2, TERMINAL_TOP, SCROLL_WIDTH + 4, TERMINAL_HEIGHT, mouseX, mouseY)) {
graphics.setTooltipForNextFrame(font, Component.translatable(
"screen.redstoner.storage_terminal.summary",
menu.page() + 1, menu.pageCount(), menu.containerCount(), menu.totalEntries()),
mouseX, mouseY);
}
renderPlayerInventory(graphics, x, y, mouseX, mouseY);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (snapshot.status() == StorageTerminalStatus.READY && scrollY != 0.0D && insidePanel(mouseX, mouseY)) {
if (scrollY != 0.0D && isHovering(7, TERMINAL_TOP, 169, TERMINAL_HEIGHT, mouseX, mouseY)) {
requestPage(requestedPage + (scrollY < 0.0D ? 1 : -1));
return true;
}
@@ -89,156 +114,75 @@ public final class StorageTerminalScreen extends Screen {
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (super.mouseClicked(event, doubleClick)) return true;
if (snapshot.status() == StorageTerminalStatus.READY) {
int entryIndex = terminalEntryAt(event.x(), event.y());
if (entryIndex >= 0 && entryIndex < snapshot.entries().size()
&& (event.button() == 0 || event.button() == 1)) {
StorageTerminalSnapshotPayload.Entry entry = snapshot.entries().get(entryIndex);
send(event.button() == 1 ? StorageTerminalActionPayload.Action.TAKE_ONE
: StorageTerminalActionPayload.Action.TAKE_STACK, snapshot.page(), entryIndex, -1, entry.stack());
return true;
}
if (insideScrollBar(event.x(), event.y()) && event.button() == 0) {
double ratio = Math.max(0.0D, Math.min(0.999D,
(event.y() - panelY() - GRID_Y) / (StorageTerminalRules.PAGE_SIZE / 9.0D * SLOT_SIZE)));
requestPage((int) Math.floor(ratio * snapshot.pageCount()));
return true;
}
int playerSlot = playerSlotAt(event.x(), event.y());
if (playerSlot >= 0 && event.button() == 0 && event.hasShiftDown() && minecraft.player != null) {
ItemStack stack = minecraft.player.getInventory().getItem(playerSlot);
if (!stack.isEmpty()) {
send(StorageTerminalActionPayload.Action.DEPOSIT_SLOT, snapshot.page(), -1, playerSlot, stack);
return true;
}
}
if (event.button() == 0
&& isHovering(SCROLL_X - 2, TERMINAL_TOP, SCROLL_WIDTH + 4, TERMINAL_HEIGHT,
event.x(), event.y())) {
double ratio = Math.clamp((event.y() - topPos - TERMINAL_TOP) / TERMINAL_HEIGHT,
0.0D, 0.999999D);
requestPage((int) Math.floor(ratio * menu.pageCount()));
return true;
}
return false;
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean isPauseScreen() {
return false;
public boolean keyPressed(KeyEvent event) {
if (searchBox != null && searchBox.isFocused() && event.key() != 256) {
searchBox.keyPressed(event);
return true;
}
return super.keyPressed(event);
}
private void renderTerminalGrid(GuiGraphicsExtractor graphics, int x, int y, int mouseX, int mouseY) {
for (int index = 0; index < StorageTerminalRules.PAGE_SIZE; index++) {
int slotX = x + GRID_X + index % 9 * SLOT_SIZE;
int slotY = y + GRID_Y + index / 9 * SLOT_SIZE;
drawSlot(graphics, slotX, slotY);
if (index >= snapshot.entries().size()) continue;
StorageTerminalSnapshotPayload.Entry entry = snapshot.entries().get(index);
graphics.item(entry.stack(), slotX + 1, slotY + 1);
graphics.itemDecorations(font, entry.stack(), slotX + 1, slotY + 1, compactCount(entry.total()));
if (inside(mouseX, mouseY, slotX, slotY, SLOT_SIZE, SLOT_SIZE)) {
graphics.setTooltipForNextFrame(font, entry.stack(), mouseX, mouseY);
}
}
int trackX = x + SCROLL_X;
int trackY = y + GRID_Y;
int trackHeight = 6 * SLOT_SIZE;
graphics.fill(trackX, trackY, trackX + SCROLL_WIDTH, trackY + trackHeight, 0xFF575757);
int thumbHeight = snapshot.pageCount() <= 1 ? trackHeight : Math.max(10, trackHeight / snapshot.pageCount());
int travel = trackHeight - thumbHeight;
int thumbY = trackY + (snapshot.pageCount() <= 1 ? 0
: Math.round(travel * snapshot.page() / (float) (snapshot.pageCount() - 1)));
graphics.fill(trackX + 1, thumbY + 1, trackX + SCROLL_WIDTH - 1, thumbY + thumbHeight - 1, 0xFFD8D8D8);
graphics.text(font, Component.translatable("screen.redstoner.storage_terminal.summary",
snapshot.page() + 1, snapshot.pageCount(), snapshot.containerCount(), snapshot.totalEntries()),
x + 8, y + 131, 0xFF555555, false);
@Override
protected void containerTick() {
requestedPage = menu.page();
}
private void renderPlayerInventory(GuiGraphicsExtractor graphics, int x, int y, int mouseX, int mouseY) {
graphics.text(font, Component.translatable("container.inventory"), x + 8, y + 141, 0xFF202020, false);
if (minecraft.player == null) return;
Inventory inventory = minecraft.player.getInventory();
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventorySlot = 9 + row * 9 + column;
drawPlayerSlot(graphics, inventory, inventorySlot, x + GRID_X + column * SLOT_SIZE,
y + PLAYER_GRID_Y + row * SLOT_SIZE, mouseX, mouseY);
}
}
for (int column = 0; column < 9; column++) {
drawPlayerSlot(graphics, inventory, column, x + GRID_X + column * SLOT_SIZE,
y + HOTBAR_Y, mouseX, mouseY);
private void requestPage(int requested) {
int page = Math.clamp(requested, 0, menu.pageCount() - 1);
requestedPage = page;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
StorageTerminalMenu.PAGE_BUTTON_BASE + page);
}
}
private void drawPlayerSlot(GuiGraphicsExtractor graphics, Inventory inventory, int inventorySlot,
int x, int y, int mouseX, int mouseY) {
drawSlot(graphics, x, y);
ItemStack stack = inventory.getItem(inventorySlot);
if (stack.isEmpty()) return;
graphics.item(stack, x + 1, y + 1);
graphics.itemDecorations(font, stack, x + 1, y + 1);
if (inside(mouseX, mouseY, x, y, SLOT_SIZE, SLOT_SIZE)) {
graphics.setTooltipForNextFrame(font, stack, mouseX, mouseY);
private void searchChanged(String value) {
String previous = sentSearchValue;
searchValue = value;
requestedPage = 0;
if (value.startsWith(previous)
&& value.codePointCount(previous.length(), value.length()) == 1) {
sendMenuButton(StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE
+ value.codePointAt(previous.length()));
} else if (previous.startsWith(value)
&& previous.codePointCount(value.length(), previous.length()) == 1) {
sendMenuButton(StorageTerminalMenu.SEARCH_BACKSPACE_BUTTON);
} else {
sendMenuButton(StorageTerminalMenu.SEARCH_CLEAR_BUTTON);
value.codePoints().forEach(codePoint -> sendMenuButton(
StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE + codePoint));
}
sentSearchValue = value;
}
private void sendMenuButton(int buttonId) {
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
}
}
private static void drawSlot(GuiGraphicsExtractor graphics, int x, int y) {
graphics.fill(x, y, x + SLOT_SIZE, y + SLOT_SIZE, 0xFF373737);
graphics.fill(x + 1, y + 1, x + SLOT_SIZE - 1, y + SLOT_SIZE - 1, 0xFF8B8B8B);
private void extractScrollBar(GuiGraphicsExtractor graphics) {
int x = leftPos + SCROLL_X;
int y = topPos + TERMINAL_TOP;
graphics.fill(x, y, x + SCROLL_WIDTH, y + TERMINAL_HEIGHT, 0xFF8B8B8B);
int pageCount = menu.pageCount();
int handleHeight = Math.max(8, TERMINAL_HEIGHT / pageCount);
int travel = TERMINAL_HEIGHT - handleHeight;
int handleY = y + (pageCount <= 1 ? 0 : menu.page() * travel / (pageCount - 1));
graphics.fill(x, handleY, x + SCROLL_WIDTH, handleY + handleHeight, 0xFFE0E0E0);
graphics.outline(x, y, SCROLL_WIDTH, TERMINAL_HEIGHT, 0xFF373737);
}
private void requestPage(int page) {
requestedPage = Math.max(0, Math.min(snapshot.pageCount() - 1, page));
refreshCooldown = REFRESH_TICKS;
send(StorageTerminalActionPayload.Action.REQUEST_PAGE, requestedPage, -1, -1, ItemStack.EMPTY);
}
private void send(StorageTerminalActionPayload.Action action, int page, int entryIndex,
int playerSlot, ItemStack expected) {
if (ClientPlayNetworking.canSend(StorageTerminalActionPayload.TYPE)) {
ClientPlayNetworking.send(new StorageTerminalActionPayload(
snapshot.pos(), action, page, entryIndex, playerSlot, expected));
}
}
private int terminalEntryAt(double mouseX, double mouseY) {
int relativeX = (int) mouseX - panelX() - GRID_X;
int relativeY = (int) mouseY - panelY() - GRID_Y;
if (relativeX < 0 || relativeY < 0 || relativeX >= 9 * SLOT_SIZE || relativeY >= 6 * SLOT_SIZE) return -1;
return relativeY / SLOT_SIZE * 9 + relativeX / SLOT_SIZE;
}
private int playerSlotAt(double mouseX, double mouseY) {
int relativeX = (int) mouseX - panelX() - GRID_X;
if (relativeX < 0 || relativeX >= 9 * SLOT_SIZE) return -1;
int column = relativeX / SLOT_SIZE;
int mainY = (int) mouseY - panelY() - PLAYER_GRID_Y;
if (mainY >= 0 && mainY < 3 * SLOT_SIZE) return 9 + mainY / SLOT_SIZE * 9 + column;
int hotbarY = (int) mouseY - panelY() - HOTBAR_Y;
return hotbarY >= 0 && hotbarY < SLOT_SIZE ? column : -1;
}
private boolean insideScrollBar(double mouseX, double mouseY) {
return inside(mouseX, mouseY, panelX() + SCROLL_X, panelY() + GRID_Y,
SCROLL_WIDTH, 6 * SLOT_SIZE);
}
private boolean insidePanel(double mouseX, double mouseY) {
return inside(mouseX, mouseY, panelX(), panelY(), PANEL_WIDTH, PANEL_HEIGHT);
}
private static boolean inside(double mouseX, double mouseY, int x, int y, int width, int height) {
return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height;
}
private int panelX() {
return (width - PANEL_WIDTH) / 2;
}
private int panelY() {
return (height - PANEL_HEIGHT) / 2;
}
private static String compactCount(int count) {
if (count >= 1_000_000) return (count / 1_000_000) + "m";
if (count >= 10_000) return (count / 1_000) + "k";
return Integer.toString(count);
}
}
@@ -4,11 +4,7 @@ import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalNetwork;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalService;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.level.ServerPlayer;
@@ -16,7 +12,7 @@ import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
public final class RedStonerNetworking {
public static final int PROTOCOL_VERSION = 6;
public static final int PROTOCOL_VERSION = 7;
private static boolean initialized;
private RedStonerNetworking() {
@@ -29,8 +25,6 @@ public final class RedStonerNetworking {
initialized = true;
PayloadTypeRegistry.clientboundPlay().register(OpenComputerPayload.TYPE, OpenComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenPersonalComputerPayload.TYPE, OpenPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(StorageTerminalSnapshotPayload.TYPE,
StorageTerminalSnapshotPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveComputerProgramPayload.TYPE, SaveComputerProgramPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(PlaceAtlasLandmarkPayload.TYPE, PlaceAtlasLandmarkPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(RequestPersonalComputerSnapshotPayload.TYPE,
@@ -39,8 +33,6 @@ public final class RedStonerNetworking {
AcknowledgePersonalComputerIntroPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(ShutdownPersonalComputerPayload.TYPE,
ShutdownPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(StorageTerminalActionPayload.TYPE,
StorageTerminalActionPayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(SaveComputerProgramPayload.TYPE, (payload, context) ->
context.server().execute(() -> saveProgram(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(PlaceAtlasLandmarkPayload.TYPE, (payload, context) ->
@@ -51,8 +43,6 @@ public final class RedStonerNetworking {
context.server().execute(() -> acknowledgePersonalComputerIntro(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(ShutdownPersonalComputerPayload.TYPE, (payload, context) ->
context.server().execute(() -> shutdownPersonalComputer(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(StorageTerminalActionPayload.TYPE, (payload, context) ->
context.server().execute(() -> storageTerminalAction(context.player(), payload)));
RedStonerMod.LOGGER.debug("[{}] Networking contract v{} registered.", RedStonerMod.DISPLAY_NAME, PROTOCOL_VERSION);
}
@@ -87,23 +77,6 @@ public final class RedStonerNetworking {
}
}
public static void openStorageTerminal(ServerPlayer player, BlockPos pos) {
sendStorageTerminalSnapshot(player, pos, StorageTerminalService.snapshot(player, pos, 0));
}
private static void storageTerminalAction(ServerPlayer player, StorageTerminalActionPayload payload) {
sendStorageTerminalSnapshot(player, payload.pos(), StorageTerminalService.handle(player, payload));
}
private static void sendStorageTerminalSnapshot(ServerPlayer player, BlockPos pos,
StorageTerminalNetwork.Snapshot snapshot) {
if (!ServerPlayNetworking.canSend(player, StorageTerminalSnapshotPayload.TYPE)) return;
List<StorageTerminalSnapshotPayload.Entry> entries = snapshot.entries().stream()
.map(entry -> new StorageTerminalSnapshotPayload.Entry(entry.stack(), entry.total())).toList();
ServerPlayNetworking.send(player, new StorageTerminalSnapshotPayload(pos, snapshot.status(), snapshot.page(),
snapshot.pageCount(), snapshot.containerCount(), snapshot.totalEntries(), entries));
}
private static void requestPersonalComputerSnapshot(ServerPlayer player,
RequestPersonalComputerSnapshotPayload payload) {
PersonalComputerBlockEntity computer = validatedPersonalComputer(
@@ -1,56 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.world.item.ItemStack;
public record StorageTerminalActionPayload(BlockPos pos, Action action, int page, int entryIndex,
int playerSlot, ItemStack expected) implements CustomPacketPayload {
public static final Type<StorageTerminalActionPayload> TYPE =
new Type<>(RedStonerMod.id("c2s/storage_terminal_action"));
public static final StreamCodec<RegistryFriendlyByteBuf, StorageTerminalActionPayload> STREAM_CODEC =
StreamCodec.of(StorageTerminalActionPayload::write, StorageTerminalActionPayload::read);
public StorageTerminalActionPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
action = action == null ? Action.REQUEST_PAGE : action;
page = Math.max(0, Math.min(StorageTerminalRules.MAX_PAGE_COUNT - 1, page));
entryIndex = entryIndex >= 0 && entryIndex < StorageTerminalRules.PAGE_SIZE ? entryIndex : -1;
playerSlot = playerSlot >= 0 && playerSlot < StorageTerminalRules.PLAYER_STORAGE_SLOTS ? playerSlot : -1;
expected = expected == null || expected.isEmpty() ? ItemStack.EMPTY : expected.copyWithCount(1);
}
private static StorageTerminalActionPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
int ordinal = buffer.readVarInt();
Action[] values = Action.values();
Action action = ordinal >= 0 && ordinal < values.length ? values[ordinal] : Action.REQUEST_PAGE;
return new StorageTerminalActionPayload(pos, action, buffer.readVarInt(), buffer.readVarInt(),
buffer.readVarInt(), ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC.decode(buffer));
}
private static void write(RegistryFriendlyByteBuf buffer, StorageTerminalActionPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.action.ordinal());
buffer.writeVarInt(payload.page);
buffer.writeVarInt(payload.entryIndex);
buffer.writeVarInt(payload.playerSlot);
ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC.encode(buffer, payload.expected);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public enum Action {
REQUEST_PAGE,
TAKE_STACK,
TAKE_ONE,
DEPOSIT_SLOT
}
}
@@ -1,80 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.world.item.ItemStack;
public record StorageTerminalSnapshotPayload(BlockPos pos, StorageTerminalStatus status, int page,
int pageCount, int containerCount, int totalEntries, List<Entry> entries) implements CustomPacketPayload {
public static final Type<StorageTerminalSnapshotPayload> TYPE =
new Type<>(RedStonerMod.id("s2c/storage_terminal_snapshot"));
public static final StreamCodec<RegistryFriendlyByteBuf, StorageTerminalSnapshotPayload> STREAM_CODEC =
StreamCodec.of(StorageTerminalSnapshotPayload::write, StorageTerminalSnapshotPayload::read);
public StorageTerminalSnapshotPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
status = status == null ? StorageTerminalStatus.INVALID_TERMINAL : status;
pageCount = Math.max(1, Math.min(StorageTerminalRules.MAX_PAGE_COUNT, pageCount));
page = Math.max(0, Math.min(pageCount - 1, page));
containerCount = Math.max(0, Math.min(StorageTerminalRules.MAX_CONTAINERS, containerCount));
totalEntries = Math.max(0, Math.min(StorageTerminalRules.MAX_ENTRIES, totalEntries));
entries = entries == null ? List.of() : List.copyOf(entries.stream()
.limit(StorageTerminalRules.PAGE_SIZE).toList());
}
private static StorageTerminalSnapshotPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
StorageTerminalStatus status = status(buffer.readVarInt());
int page = buffer.readVarInt();
int pageCount = buffer.readVarInt();
int containerCount = buffer.readVarInt();
int totalEntries = buffer.readVarInt();
int size = buffer.readVarInt();
if (size < 0 || size > StorageTerminalRules.PAGE_SIZE) {
throw new IllegalArgumentException("Storage terminal page exceeds " + StorageTerminalRules.PAGE_SIZE);
}
List<Entry> entries = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
entries.add(new Entry(ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC.decode(buffer), buffer.readVarInt()));
}
return new StorageTerminalSnapshotPayload(pos, status, page, pageCount, containerCount, totalEntries, entries);
}
private static void write(RegistryFriendlyByteBuf buffer, StorageTerminalSnapshotPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.status.ordinal());
buffer.writeVarInt(payload.page);
buffer.writeVarInt(payload.pageCount);
buffer.writeVarInt(payload.containerCount);
buffer.writeVarInt(payload.totalEntries);
buffer.writeVarInt(payload.entries.size());
for (Entry entry : payload.entries) {
ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC.encode(buffer, entry.stack);
buffer.writeVarInt(entry.total);
}
}
private static StorageTerminalStatus status(int ordinal) {
StorageTerminalStatus[] values = StorageTerminalStatus.values();
return values[Math.max(0, Math.min(values.length - 1, ordinal))];
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
public record Entry(ItemStack stack, int total) {
public Entry {
stack = stack == null || stack.isEmpty() ? ItemStack.EMPTY : stack.copyWithCount(1);
total = stack.isEmpty() ? 0 : Math.max(1, total);
}
}
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.redstoner.registry;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.inventory.MenuType;
public final class RedStonerMenus {
public static final MenuType<StorageTerminalMenu> STORAGE_TERMINAL = Registry.register(
BuiltInRegistries.MENU,
RedStonerMod.id("storage_terminal"),
new MenuType<>(StorageTerminalMenu::new, FeatureFlags.VANILLA_SET)
);
private RedStonerMenus() {
}
public static void initialize() {
RedStonerMod.LOGGER.debug("[{}] Storage-terminal menu registered.", RedStonerMod.DISPLAY_NAME);
}
}
@@ -12,12 +12,12 @@ public final class StorageTerminalInventory {
}
public static List<Entry> aggregate(List<? extends Container> containers) {
Map<StackKey, MutableEntry> grouped = new LinkedHashMap<>();
Map<StackIdentity, MutableEntry> grouped = new LinkedHashMap<>();
for (Container container : containers) {
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack stack = container.getItem(slot);
if (stack.isEmpty()) continue;
StackKey key = new StackKey(stack);
StackIdentity key = identity(stack);
MutableEntry entry = grouped.computeIfAbsent(key, ignored -> new MutableEntry(key.stack));
entry.total = saturatedAdd(entry.total, stack.getCount());
}
@@ -27,7 +27,10 @@ public final class StorageTerminalInventory {
return List.copyOf(result);
}
/** Inserts into compatible existing stacks first, then into empty slots. Mutates {@code source}. */
/**
* Fills each container in discovery order, preferring its compatible stacks before its empty slots.
* Mutates {@code source} and only spills into the next container when the current one cannot accept more.
*/
public static int insert(List<? extends Container> containers, ItemStack source) {
if (source == null || source.isEmpty()) return 0;
int before = source.getCount();
@@ -43,8 +46,6 @@ public final class StorageTerminalInventory {
source.shrink(moved);
container.setChanged();
}
}
for (Container container : containers) {
for (int slot = 0; slot < container.getContainerSize() && !source.isEmpty(); slot++) {
if (!container.getItem(slot).isEmpty() || !container.canPlaceItem(slot, source)) continue;
int limit = Math.min(container.getMaxStackSize(source), source.getMaxStackSize());
@@ -83,10 +84,14 @@ public final class StorageTerminalInventory {
public record Entry(ItemStack stack, int total) {
public Entry {
stack = stack == null || stack.isEmpty() ? ItemStack.EMPTY : stack.copyWithCount(1);
total = stack.isEmpty() ? 0 : Math.max(1, total);
total = stack.isEmpty() ? 0 : Math.max(0, total);
}
}
static StackIdentity identity(ItemStack stack) {
return new StackIdentity(stack);
}
private static final class MutableEntry {
private final ItemStack stack;
private int total;
@@ -96,11 +101,11 @@ public final class StorageTerminalInventory {
}
}
private static final class StackKey {
static final class StackIdentity {
private final ItemStack stack;
private final int hash;
private StackKey(ItemStack stack) {
private StackIdentity(ItemStack stack) {
this.stack = stack.copyWithCount(1);
this.hash = ItemStack.hashItemAndComponents(this.stack);
}
@@ -112,7 +117,12 @@ public final class StorageTerminalInventory {
@Override
public boolean equals(Object other) {
return other instanceof StackKey key && ItemStack.isSameItemSameComponents(stack, key.stack);
return other instanceof StackIdentity identity
&& ItemStack.isSameItemSameComponents(stack, identity.stack);
}
ItemStack representative() {
return stack.copyWithCount(1);
}
}
}
@@ -0,0 +1,52 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraft.world.item.ItemStack;
/** Keeps terminal slot identities stable for the lifetime of one search layout. */
final class StorageTerminalLayout {
private static final StorageTerminalInventory.Entry EMPTY_ENTRY =
new StorageTerminalInventory.Entry(ItemStack.EMPTY, 0);
private final List<StorageTerminalInventory.StackIdentity> order = new ArrayList<>();
private Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> liveByIdentity = Map.of();
void update(List<StorageTerminalInventory.Entry> liveEntries, boolean resetOrder) {
Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> live =
new LinkedHashMap<>();
for (StorageTerminalInventory.Entry entry : liveEntries) {
live.put(StorageTerminalInventory.identity(entry.stack()), entry);
}
if (resetOrder) order.clear();
Set<StorageTerminalInventory.StackIdentity> known = new HashSet<>(order);
for (StorageTerminalInventory.StackIdentity identity : live.keySet()) {
if (known.add(identity)) order.add(identity);
}
liveByIdentity = Map.copyOf(live);
}
int size() {
return order.size();
}
List<StorageTerminalInventory.Entry> page(int requestedPage) {
int start = StorageTerminalRules.pageStart(requestedPage, order.size());
List<StorageTerminalInventory.Entry> entries = new ArrayList<>(StorageTerminalRules.PAGE_SIZE);
for (int slot = 0; slot < StorageTerminalRules.PAGE_SIZE; slot++) {
int orderedIndex = start + slot;
if (orderedIndex >= order.size()) {
entries.add(EMPTY_ENTRY);
continue;
}
StorageTerminalInventory.StackIdentity identity = order.get(orderedIndex);
StorageTerminalInventory.Entry live = liveByIdentity.get(identity);
entries.add(live == null
? new StorageTerminalInventory.Entry(identity.representative(), 0) : live);
}
return List.copyOf(entries);
}
}
@@ -0,0 +1,336 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
import java.util.List;
import java.util.Locale;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerData;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.SimpleContainerData;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
/** A server-authoritative aggregate view over the connected physical storage. */
public final class StorageTerminalMenu extends AbstractContainerMenu {
public static final int TERMINAL_SLOT_COUNT = StorageTerminalRules.PAGE_SIZE;
public static final int PAGE_BUTTON_BASE = 10;
public static final int SEARCH_CLEAR_BUTTON = 100;
public static final int SEARCH_BACKSPACE_BUTTON = 101;
public static final int SEARCH_CODEPOINT_BUTTON_BASE = 1_000;
private static final int PLAYER_INVENTORY_Y = 140;
private static final int REFRESH_INTERVAL = 20;
private static final double MAX_DISTANCE_SQUARED = 64.0D;
private static final int STATUS_DATA = 0;
private static final int PAGE_DATA = 1;
private static final int PAGE_COUNT_DATA = 2;
private static final int CONTAINER_COUNT_DATA = 3;
private static final int TOTAL_ENTRIES_DATA = 4;
private static final int TOTALS_DATA = 5;
private static final int WORDS_PER_TOTAL = 2;
private static final int DATA_COUNT = TOTALS_DATA + TERMINAL_SLOT_COUNT * WORDS_PER_TOTAL;
private final SimpleContainer display = new SimpleContainer(TERMINAL_SLOT_COUNT);
private final ContainerData data;
private final ServerPlayer serverPlayer;
private final BlockPos terminalPos;
private StorageTerminalNetwork.Discovery discovery =
StorageTerminalNetwork.Discovery.error(StorageTerminalStatus.INVALID_TERMINAL);
private final StorageTerminalLayout layout = new StorageTerminalLayout();
private List<StorageTerminalInventory.Entry> entries = List.of();
private String searchQuery = "";
private int refreshCooldown = REFRESH_INTERVAL;
public StorageTerminalMenu(int containerId, Inventory inventory) {
this(containerId, inventory, new SimpleContainerData(DATA_COUNT), null, null);
}
public StorageTerminalMenu(int containerId, Inventory inventory, ServerPlayer player, BlockPos pos) {
this(containerId, inventory, new SimpleContainerData(DATA_COUNT), player, pos.immutable());
refresh(0, true);
}
private StorageTerminalMenu(int containerId, Inventory inventory, ContainerData data,
ServerPlayer serverPlayer, BlockPos terminalPos) {
super(RedStonerMenus.STORAGE_TERMINAL, containerId);
checkContainerDataCount(data, DATA_COUNT);
this.data = data;
this.serverPlayer = serverPlayer;
this.terminalPos = terminalPos;
for (int row = 0; row < 6; row++) {
for (int column = 0; column < 9; column++) {
int slot = column + row * 9;
addSlot(new TerminalDisplaySlot(display, slot, 8 + column * 18, 18 + row * 18));
}
}
addStandardInventorySlots(inventory, 8, PLAYER_INVENTORY_Y);
addDataSlots(data);
}
public static void open(ServerPlayer player, BlockPos pos) {
if (player.distanceToSqr(Vec3.atCenterOf(pos)) > MAX_DISTANCE_SQUARED) return;
player.openMenu(new SimpleMenuProvider(
(containerId, inventory, ignored) -> new StorageTerminalMenu(containerId, inventory, player, pos),
Component.translatable("screen.redstoner.storage_terminal.title")));
}
public StorageTerminalStatus status() {
StorageTerminalStatus[] values = StorageTerminalStatus.values();
return values[Math.clamp(data.get(STATUS_DATA), 0, values.length - 1)];
}
public int page() {
return Math.max(0, data.get(PAGE_DATA));
}
public int pageCount() {
return Math.max(1, data.get(PAGE_COUNT_DATA));
}
public int containerCount() {
return Math.max(0, data.get(CONTAINER_COUNT_DATA));
}
public int totalEntries() {
return Math.max(0, data.get(TOTAL_ENTRIES_DATA));
}
public int totalAt(int slot) {
if (slot < 0 || slot >= TERMINAL_SLOT_COUNT) return 0;
int base = TOTALS_DATA + slot * WORDS_PER_TOTAL;
return StorageTerminalRules.totalFromWords(data.get(base), data.get(base + 1));
}
public Component statusMessage() {
return Component.translatable("screen.redstoner.storage_terminal.error."
+ status().name().toLowerCase(Locale.ROOT));
}
@Override
public boolean clickMenuButton(Player player, int buttonId) {
if (serverPlayer == null || player != serverPlayer || !stillValid(player)) return false;
int requestedPage = buttonId - PAGE_BUTTON_BASE;
if (requestedPage >= 0 && requestedPage < StorageTerminalRules.MAX_PAGE_COUNT) {
refresh(requestedPage, false);
broadcastChanges();
return true;
}
if (buttonId == SEARCH_CLEAR_BUTTON) {
searchQuery = "";
} else if (buttonId == SEARCH_BACKSPACE_BUTTON) {
if (searchQuery.isEmpty()) return true;
searchQuery = searchQuery.substring(0,
searchQuery.offsetByCodePoints(searchQuery.length(), -1));
} else {
int codePoint = buttonId - SEARCH_CODEPOINT_BUTTON_BASE;
if (!isSearchCodePoint(codePoint)) return false;
String appended = new String(Character.toChars(codePoint));
if (searchQuery.length() + appended.length() > StorageTerminalRules.MAX_SEARCH_LENGTH) return false;
searchQuery += appended;
}
refresh(0, true);
broadcastChanges();
return true;
}
@Override
public void clicked(int slotId, int button, ContainerInput input, Player player) {
if (slotId >= 0 && slotId < TERMINAL_SLOT_COUNT) {
if (input == ContainerInput.QUICK_MOVE) {
if (serverPlayer != null) quickMoveStack(player, slotId);
return;
}
if (input == ContainerInput.PICKUP && (button == 0 || button == 1)) {
if (serverPlayer != null && player == serverPlayer && stillValid(player)) {
if (getCarried().isEmpty()) takeToCarried(slotId, button == 1);
else depositCarried(button == 1);
}
return;
}
return;
}
super.clicked(slotId, button, input, player);
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
if (serverPlayer == null || player != serverPlayer || !stillValid(player)
|| index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index < TERMINAL_SLOT_COUNT) return takeIntoPlayer(index);
Slot sourceSlot = slots.get(index);
if (!sourceSlot.hasItem() || !sourceSlot.mayPickup(player)) return ItemStack.EMPTY;
if (!ready()) return ItemStack.EMPTY;
ItemStack source = sourceSlot.getItem();
ItemStack original = source.copy();
int moved = StorageTerminalInventory.insert(discovery.inventoryContainers(), source);
if (moved <= 0) return ItemStack.EMPTY;
if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY);
else sourceSlot.setChanged();
sourceSlot.onTake(player, source);
refresh(page(), false);
broadcastChanges();
return original;
}
@Override
public boolean stillValid(Player player) {
if (serverPlayer == null || terminalPos == null) return true;
return player == serverPlayer
&& player.distanceToSqr(Vec3.atCenterOf(terminalPos)) <= MAX_DISTANCE_SQUARED
&& player.level().getBlockState(terminalPos).is(RedStonerRegistries.STORAGE_TERMINAL);
}
@Override
public void broadcastChanges() {
if (serverPlayer != null && --refreshCooldown <= 0) refresh(page(), false);
super.broadcastChanges();
}
@Override
public boolean canDragTo(Slot slot) {
return !(slot instanceof TerminalDisplaySlot) && super.canDragTo(slot);
}
@Override
public boolean canTakeItemForPickAll(ItemStack stack, Slot slot) {
return !(slot instanceof TerminalDisplaySlot) && super.canTakeItemForPickAll(stack, slot);
}
private void refresh(int requestedPage, boolean resetOrder) {
if (serverPlayer == null || terminalPos == null) return;
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
List<StorageTerminalInventory.Entry> liveEntries =
StorageTerminalNetwork.filteredEntries(discovery, searchQuery);
layout.update(liveEntries, resetOrder);
int page = discovery.status() == StorageTerminalStatus.READY
? StorageTerminalRules.clampPage(requestedPage, layout.size()) : 0;
int pageCount = discovery.status() == StorageTerminalStatus.READY
? StorageTerminalRules.pageCount(layout.size()) : 1;
entries = discovery.status() == StorageTerminalStatus.READY ? layout.page(page) : List.of();
data.set(STATUS_DATA, discovery.status().ordinal());
data.set(PAGE_DATA, page);
data.set(PAGE_COUNT_DATA, pageCount);
data.set(CONTAINER_COUNT_DATA, discovery.containers().size());
data.set(TOTAL_ENTRIES_DATA, liveEntries.size());
for (int slot = 0; slot < TERMINAL_SLOT_COUNT; slot++) {
StorageTerminalInventory.Entry entry = slot < entries.size() ? entries.get(slot) : null;
display.setItem(slot, entry == null || entry.total() <= 0
? ItemStack.EMPTY : entry.stack().copyWithCount(1));
int total = entry == null ? 0 : entry.total();
int base = TOTALS_DATA + slot * WORDS_PER_TOTAL;
data.set(base, StorageTerminalRules.totalLowWord(total));
data.set(base + 1, StorageTerminalRules.totalHighWord(total));
}
refreshCooldown = REFRESH_INTERVAL;
}
private static boolean isSearchCodePoint(int codePoint) {
return Character.isValidCodePoint(codePoint)
&& !(codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE)
&& !Character.isISOControl(codePoint);
}
private boolean ready() {
if (serverPlayer == null || terminalPos == null) return false;
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
return discovery.status() == StorageTerminalStatus.READY;
}
private void takeToCarried(int slot, boolean single) {
if (!ready() || slot >= entries.size()) return;
StorageTerminalInventory.Entry entry = entries.get(slot);
if (entry.stack().isEmpty() || entry.total() <= 0) return;
int requested = single ? 1 : entry.stack().getMaxStackSize();
ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(),
Math.min(entry.total(), requested));
if (extracted.isEmpty()) return;
setCarried(extracted);
refresh(page(), false);
broadcastChanges();
}
private void depositCarried(boolean single) {
if (!ready()) return;
ItemStack carried = getCarried();
int offeredCount = single ? 1 : carried.getCount();
ItemStack offered = carried.copyWithCount(offeredCount);
int moved = StorageTerminalInventory.insert(discovery.inventoryContainers(), offered);
if (moved <= 0) return;
carried.shrink(moved);
if (carried.isEmpty()) setCarried(ItemStack.EMPTY);
refresh(page(), false);
broadcastChanges();
}
private ItemStack takeIntoPlayer(int slot) {
if (!ready() || slot >= entries.size()) return ItemStack.EMPTY;
StorageTerminalInventory.Entry entry = entries.get(slot);
if (entry.stack().isEmpty() || entry.total() <= 0) return ItemStack.EMPTY;
int requested = Math.min(entry.total(), entry.stack().getMaxStackSize());
ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(), requested);
if (extracted.isEmpty()) return ItemStack.EMPTY;
ItemStack original = extracted.copy();
int inserted = insertPlayer(serverPlayer, extracted);
if (!extracted.isEmpty()) StorageTerminalInventory.insert(discovery.inventoryContainers(), extracted);
if (inserted <= 0) return ItemStack.EMPTY;
refresh(page(), false);
broadcastChanges();
return original.copyWithCount(inserted);
}
private static int insertPlayer(ServerPlayer player, ItemStack source) {
int before = source.getCount();
var slots = SanctuaryPlayerInventoryView.unlockedRows(player);
for (var slot : slots) {
if (source.isEmpty()) break;
ItemStack target = slot.stack();
if (target.isEmpty() || !ItemStack.isSameItemSameComponents(target, source)
|| !slot.container().canPlaceItem(slot.slot(), source)) continue;
int limit = Math.min(target.getMaxStackSize(), slot.container().getMaxStackSize(source));
int moved = Math.min(source.getCount(), Math.max(0, limit - target.getCount()));
if (moved <= 0) continue;
target.grow(moved);
source.shrink(moved);
slot.markChanged();
}
for (var slot : slots) {
if (source.isEmpty()) break;
if (!slot.stack().isEmpty() || !slot.container().canPlaceItem(slot.slot(), source)) continue;
int limit = Math.min(source.getMaxStackSize(), slot.container().getMaxStackSize(source));
int moved = Math.min(source.getCount(), Math.max(0, limit));
if (moved <= 0) continue;
slot.container().setItem(slot.slot(), source.copyWithCount(moved));
source.shrink(moved);
slot.markChanged();
}
return before - source.getCount();
}
private static final class TerminalDisplaySlot extends Slot {
private TerminalDisplaySlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override
public boolean mayPlace(ItemStack stack) {
return false;
}
@Override
public boolean mayPickup(Player player) {
return hasItem();
}
}
}
@@ -48,16 +48,11 @@ public final class StorageTerminalNetwork {
return new Discovery(StorageTerminalStatus.READY, containers);
}
public static Snapshot snapshot(Discovery discovery, int requestedPage) {
if (discovery.status() != StorageTerminalStatus.READY) {
return new Snapshot(discovery.status(), 0, 1, 0, 0, List.of());
}
List<StorageTerminalInventory.Entry> all = StorageTerminalInventory.aggregate(discovery.containers());
int page = StorageTerminalRules.clampPage(requestedPage, all.size());
int start = StorageTerminalRules.pageStart(page, all.size());
int end = Math.min(all.size(), start + StorageTerminalRules.PAGE_SIZE);
return new Snapshot(StorageTerminalStatus.READY, page, StorageTerminalRules.pageCount(all.size()),
discovery.containers().size(), all.size(), all.subList(start, end));
public static List<StorageTerminalInventory.Entry> filteredEntries(
Discovery discovery, String searchQuery) {
if (discovery.status() != StorageTerminalStatus.READY) return List.of();
return StorageTerminalSearch.filter(
StorageTerminalInventory.aggregate(discovery.containers()), searchQuery);
}
private static BaseContainerBlockEntity storageEntity(ServerLevel level, BlockPos pos) {
@@ -81,12 +76,4 @@ public final class StorageTerminalNetwork {
return containers;
}
}
public record Snapshot(StorageTerminalStatus status, int page, int pageCount, int containerCount,
int totalEntries, List<StorageTerminalInventory.Entry> entries) {
public Snapshot {
status = status == null ? StorageTerminalStatus.INVALID_TERMINAL : status;
entries = entries == null ? List.of() : List.copyOf(entries);
}
}
}
@@ -6,7 +6,7 @@ public final class StorageTerminalRules {
public static final int SLOTS_PER_CONTAINER = 27;
public static final int MAX_ENTRIES = MAX_CONTAINERS * SLOTS_PER_CONTAINER;
public static final int MAX_PAGE_COUNT = MAX_ENTRIES / PAGE_SIZE;
public static final int PLAYER_STORAGE_SLOTS = 36;
public static final int MAX_SEARCH_LENGTH = 32;
private StorageTerminalRules() {
}
@@ -23,4 +23,32 @@ public final class StorageTerminalRules {
public static int pageStart(int requestedPage, int entryCount) {
return clampPage(requestedPage, entryCount) * PAGE_SIZE;
}
public static int totalLowWord(int total) {
return Math.max(0, total) & 0xFFFF;
}
public static int totalHighWord(int total) {
return Math.max(0, total) >>> 16;
}
public static int totalFromWords(int low, int high) {
return (low & 0xFFFF) | (high & 0xFFFF) << 16;
}
public static String compactCount(int count) {
int bounded = Math.max(0, count);
if (bounded < 1_000) return Integer.toString(bounded);
if (bounded < 10_000) return decimalUnit(bounded, 1_000, "K");
if (bounded < 1_000_000) return (bounded / 1_000) + "K";
if (bounded < 10_000_000) return decimalUnit(bounded, 1_000_000, "M");
return (bounded / 1_000_000) + "M";
}
private static String decimalUnit(int count, int unit, String suffix) {
int tenths = count / (unit / 10);
int decimal = tenths % 10;
return decimal == 0 ? (tenths / 10) + suffix
: (tenths / 10) + "." + decimal + suffix;
}
}
@@ -0,0 +1,51 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** Server-side matching for the bounded terminal search field. */
public final class StorageTerminalSearch {
private static final Pattern COMBINING_MARKS = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\p{L}\\p{N}]+");
private StorageTerminalSearch() {
}
public static List<StorageTerminalInventory.Entry> filter(
List<StorageTerminalInventory.Entry> entries, String query) {
String normalizedQuery = normalize(query);
if (normalizedQuery.isEmpty()) return List.copyOf(entries);
String[] terms = normalizedQuery.split(" ");
List<StorageTerminalInventory.Entry> matches = new ArrayList<>();
for (StorageTerminalInventory.Entry entry : entries) {
if (matches(entry.stack(), terms)) matches.add(entry);
}
return List.copyOf(matches);
}
static String normalize(String value) {
if (value == null || value.isBlank()) return "";
String withoutMarks = COMBINING_MARKS.matcher(
Normalizer.normalize(value, Normalizer.Form.NFD)).replaceAll("");
return NON_ALPHANUMERIC.matcher(withoutMarks.toLowerCase(Locale.ROOT))
.replaceAll(" ").strip();
}
private static boolean matches(ItemStack stack, String[] terms) {
StringBuilder searchable = new StringBuilder(stack.getHoverName().getString());
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
if (id != null) searchable.append(' ').append(id);
searchable.append(' ').append(stack.getItem().getDescriptionId());
String normalized = normalize(searchable.toString());
for (String term : terms) {
if (!normalized.contains(term)) return false;
}
return true;
}
}
@@ -1,109 +0,0 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import fr.koka99cab.sanctuary26.redstoner.network.StorageTerminalActionPayload;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class StorageTerminalService {
private static final double MAX_DISTANCE_SQUARED = 64.0D;
private StorageTerminalService() {
}
public static StorageTerminalNetwork.Snapshot snapshot(ServerPlayer player, BlockPos pos, int page) {
if (player.distanceToSqr(Vec3.atCenterOf(pos)) > MAX_DISTANCE_SQUARED) {
return StorageTerminalNetwork.snapshot(
StorageTerminalNetwork.Discovery.error(StorageTerminalStatus.INVALID_TERMINAL), 0);
}
return StorageTerminalNetwork.snapshot(StorageTerminalNetwork.discover(player, pos), page);
}
public static StorageTerminalNetwork.Snapshot handle(ServerPlayer player, StorageTerminalActionPayload payload) {
if (player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > MAX_DISTANCE_SQUARED) {
return StorageTerminalNetwork.snapshot(
StorageTerminalNetwork.Discovery.error(StorageTerminalStatus.INVALID_TERMINAL), 0);
}
StorageTerminalNetwork.Discovery discovery = StorageTerminalNetwork.discover(player, payload.pos());
StorageTerminalNetwork.Snapshot before = StorageTerminalNetwork.snapshot(discovery, payload.page());
if (before.status() != StorageTerminalStatus.READY
|| payload.action() == StorageTerminalActionPayload.Action.REQUEST_PAGE) return before;
boolean changed = switch (payload.action()) {
case TAKE_STACK -> take(player, discovery, before, payload, false);
case TAKE_ONE -> take(player, discovery, before, payload, true);
case DEPOSIT_SLOT -> deposit(player, discovery, payload);
case REQUEST_PAGE -> false;
};
if (changed) {
player.getInventory().setChanged();
player.containerMenu.broadcastChanges();
player.inventoryMenu.broadcastChanges();
}
return StorageTerminalNetwork.snapshot(discovery, before.page());
}
private static boolean take(ServerPlayer player, StorageTerminalNetwork.Discovery discovery,
StorageTerminalNetwork.Snapshot snapshot, StorageTerminalActionPayload payload, boolean single) {
if (payload.entryIndex() < 0 || payload.entryIndex() >= snapshot.entries().size()) return false;
StorageTerminalInventory.Entry entry = snapshot.entries().get(payload.entryIndex());
if (payload.expected().isEmpty()
|| !ItemStack.isSameItemSameComponents(entry.stack(), payload.expected())) return false;
int capacity = playerCapacity(player.getInventory(), entry.stack());
int requested = single ? 1 : entry.stack().getMaxStackSize();
int amount = Math.min(entry.total(), Math.min(capacity, requested));
if (amount <= 0) return false;
ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(), amount);
if (extracted.isEmpty()) return false;
int inserted = insertPlayer(player.getInventory(), extracted);
if (!extracted.isEmpty()) StorageTerminalInventory.insert(discovery.inventoryContainers(), extracted);
return inserted > 0;
}
private static boolean deposit(ServerPlayer player, StorageTerminalNetwork.Discovery discovery,
StorageTerminalActionPayload payload) {
int slot = payload.playerSlot();
if (slot < 0 || slot >= StorageTerminalRules.PLAYER_STORAGE_SLOTS) return false;
Inventory inventory = player.getInventory();
ItemStack source = inventory.getItem(slot);
if (source.isEmpty() || payload.expected().isEmpty()
|| !ItemStack.isSameItemSameComponents(source, payload.expected())) return false;
int inserted = StorageTerminalInventory.insert(discovery.inventoryContainers(), source);
if (source.isEmpty()) inventory.setItem(slot, ItemStack.EMPTY);
return inserted > 0;
}
private static int playerCapacity(Inventory inventory, ItemStack expected) {
long capacity = 0L;
for (int slot = 0; slot < StorageTerminalRules.PLAYER_STORAGE_SLOTS; slot++) {
ItemStack stack = inventory.getItem(slot);
if (stack.isEmpty()) capacity += expected.getMaxStackSize();
else if (ItemStack.isSameItemSameComponents(stack, expected)) {
capacity += Math.max(0, stack.getMaxStackSize() - stack.getCount());
}
}
return (int) Math.min(Integer.MAX_VALUE, capacity);
}
/** Inserts into the 36 survival storage slots and mutates {@code source}. */
private static int insertPlayer(Inventory inventory, ItemStack source) {
int before = source.getCount();
for (int slot = 0; slot < StorageTerminalRules.PLAYER_STORAGE_SLOTS && !source.isEmpty(); slot++) {
ItemStack target = inventory.getItem(slot);
if (target.isEmpty() || !ItemStack.isSameItemSameComponents(target, source)) continue;
int moved = Math.min(source.getCount(), Math.max(0, target.getMaxStackSize() - target.getCount()));
if (moved <= 0) continue;
target.grow(moved);
source.shrink(moved);
}
for (int slot = 0; slot < StorageTerminalRules.PLAYER_STORAGE_SLOTS && !source.isEmpty(); slot++) {
if (!inventory.getItem(slot).isEmpty()) continue;
int moved = Math.min(source.getCount(), source.getMaxStackSize());
inventory.setItem(slot, source.copyWithCount(moved));
source.shrink(moved);
}
return before - source.getCount();
}
}
@@ -52,6 +52,8 @@
"tooltip.redstoner.particuleur": "Insert a catalyst, then power the block with redstone.",
"block.redstoner.storage_terminal": "Storage Terminal",
"screen.redstoner.storage_terminal.title": "Storage Terminal",
"screen.redstoner.storage_terminal.search": "Search…",
"screen.redstoner.storage_terminal.no_results": "No items found.",
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocks · %s types",
"screen.redstoner.storage_terminal.error.invalid_terminal": "The terminal is no longer accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "No connected chest or barrel.",
@@ -52,6 +52,8 @@
"tooltip.redstoner.particuleur": "Insérez un catalyseur puis alimentez le bloc en redstone.",
"block.redstoner.storage_terminal": "Terminal de stockage",
"screen.redstoner.storage_terminal.title": "Terminal de stockage",
"screen.redstoner.storage_terminal.search": "Rechercher…",
"screen.redstoner.storage_terminal.no_results": "Aucun objet trouvé.",
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocs · %s types",
"screen.redstoner.storage_terminal.error.invalid_terminal": "Le terminal n'est plus accessible.",
"screen.redstoner.storage_terminal.error.disconnected": "Aucun coffre ou baril connecté.",
@@ -52,6 +52,8 @@
"tooltip.redstoner.particuleur": "Вставьте катализатор и подайте сигнал редстоуна.",
"block.redstoner.storage_terminal": "Терминал хранилища",
"screen.redstoner.storage_terminal.title": "Терминал хранилища",
"screen.redstoner.storage_terminal.search": "Поиск…",
"screen.redstoner.storage_terminal.no_results": "Предметы не найдены.",
"screen.redstoner.storage_terminal.summary": "%s/%s · блоков: %s · типов: %s",
"screen.redstoner.storage_terminal.error.invalid_terminal": "Терминал больше недоступен.",
"screen.redstoner.storage_terminal.error.disconnected": "Нет подключённых сундуков или бочек.",
+1 -1
View File
@@ -31,7 +31,7 @@
"system_type": "gameplay-system",
"namespace": "redstoner",
"data_version": 3,
"network_protocol": 6,
"network_protocol": 7,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
@@ -1,6 +1,5 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
import fr.koka99cab.sanctuary26.redstoner.network.StorageTerminalActionPayload;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -25,8 +24,9 @@ public final class StorageTerminalSmoke {
Bootstrap.bootStrap();
verifyTopology();
verifyPagination();
verifyActionBounds();
verifyAggregationAndTransfers();
verifySearch();
verifyStableLayout();
}
private static void verifyTopology() {
@@ -54,17 +54,18 @@ public final class StorageTerminalSmoke {
require(StorageTerminalRules.pageCount(54) == 1, "A full visible page overflowed");
require(StorageTerminalRules.pageCount(55) == 2, "The second page did not open");
require(StorageTerminalRules.clampPage(999, 55) == 1, "A hostile page index was not clamped");
}
private static void verifyActionBounds() {
StorageTerminalActionPayload hostile = new StorageTerminalActionPayload(BlockPos.ZERO,
StorageTerminalActionPayload.Action.DEPOSIT_SLOT, -5, 999, 999, ItemStack.EMPTY);
require(hostile.page() == 0 && hostile.entryIndex() == -1 && hostile.playerSlot() == -1,
"Hostile terminal action indexes were mapped to valid slots");
StorageTerminalActionPayload missingAction = new StorageTerminalActionPayload(
BlockPos.ZERO, null, 0, -1, -1, ItemStack.EMPTY);
require(missingAction.action() == StorageTerminalActionPayload.Action.REQUEST_PAGE,
"A missing terminal action did not fall back to a read-only request");
int networkMaximum = StorageTerminalRules.MAX_CONTAINERS
* StorageTerminalRules.SLOTS_PER_CONTAINER * 64;
require(StorageTerminalRules.totalFromWords(
(short) StorageTerminalRules.totalLowWord(networkMaximum),
(short) StorageTerminalRules.totalHighWord(networkMaximum)) == networkMaximum,
"A large aggregated quantity was truncated by menu data synchronization");
require(StorageTerminalRules.compactCount(999).equals("999")
&& StorageTerminalRules.compactCount(1_000).equals("1K")
&& StorageTerminalRules.compactCount(1_250).equals("1.2K")
&& StorageTerminalRules.compactCount(12_345).equals("12K")
&& StorageTerminalRules.compactCount(1_250_000).equals("1.2M"),
"Terminal quantities were not compacted without overstating their value");
}
private static void verifyAggregationAndTransfers() {
@@ -83,6 +84,14 @@ public final class StorageTerminalSmoke {
require(first.getItem(0).getCount() == 64 && first.getItem(1).getCount() == 6,
"Terminal deposit did not fill matching stacks before empty slots");
SimpleContainer earliest = new SimpleContainer(1);
SimpleContainer laterMatch = new SimpleContainer(1);
laterMatch.setItem(0, new ItemStack(cobblestone, 60));
ItemStack orderedDeposit = new ItemStack(cobblestone, 10);
StorageTerminalInventory.insert(List.of(earliest, laterMatch), orderedDeposit);
require(earliest.getItem(0).getCount() == 10 && laterMatch.getItem(0).getCount() == 60,
"Terminal deposit skipped the first available physical container");
List<StorageTerminalInventory.Entry> entries = StorageTerminalInventory.aggregate(List.of(first, second));
require(entries.size() == 2, "Items with distinct components were merged");
StorageTerminalInventory.Entry plain = entries.stream()
@@ -97,6 +106,55 @@ public final class StorageTerminalSmoke {
.findFirst().orElseThrow().total() == 6, "Terminal extraction did not update physical slots");
}
private static void verifySearch() {
SimpleContainer container = new SimpleContainer(4);
container.setItem(0, new ItemStack(stackable(Items.COBBLESTONE), 12));
ItemStack accented = new ItemStack(stackable(Items.DIAMOND), 2);
accented.set(DataComponents.CUSTOM_NAME, Component.literal("Élément spécial"));
container.setItem(1, accented);
ItemStack russian = new ItemStack(stackable(Items.REDSTONE), 3);
russian.set(DataComponents.CUSTOM_NAME, Component.literal("Красный камень"));
container.setItem(2, russian);
List<StorageTerminalInventory.Entry> entries = StorageTerminalInventory.aggregate(List.of(container));
require(StorageTerminalSearch.filter(entries, "minecraft:cobblestone").size() == 1,
"Terminal search did not match a namespaced item id");
require(StorageTerminalSearch.filter(entries, "cobble stone").size() == 1,
"Terminal search did not combine multiple terms");
require(StorageTerminalSearch.filter(entries, "element special").size() == 1,
"Terminal search did not ignore accents in a custom name");
require(StorageTerminalSearch.filter(entries, "красный камень").size() == 1,
"Terminal search did not preserve non-Latin keyboard input");
require(StorageTerminalSearch.filter(entries, "missing").isEmpty(),
"Terminal search retained an unrelated item");
}
private static Holder<Item> stackable(Item item) {
return new Holder.Direct<>(item, DataComponentMap.builder()
.set(DataComponents.MAX_STACK_SIZE, 64).build());
}
private static void verifyStableLayout() {
StorageTerminalInventory.Entry stone = new StorageTerminalInventory.Entry(
new ItemStack(stackable(Items.STONE)), 64);
StorageTerminalInventory.Entry dirt = new StorageTerminalInventory.Entry(
new ItemStack(stackable(Items.DIRT)), 64);
StorageTerminalInventory.Entry diamond = new StorageTerminalInventory.Entry(
new ItemStack(stackable(Items.DIAMOND)), 8);
StorageTerminalLayout layout = new StorageTerminalLayout();
layout.update(List.of(stone, dirt), true);
layout.update(List.of(dirt, diamond), false);
List<StorageTerminalInventory.Entry> page = layout.page(0);
require(page.get(0).total() == 0 && ItemStack.isSameItemSameComponents(page.get(0).stack(), stone.stack()),
"An exhausted terminal resource moved another item under the cursor");
require(ItemStack.isSameItemSameComponents(page.get(1).stack(), dirt.stack())
&& ItemStack.isSameItemSameComponents(page.get(2).stack(), diamond.stack()),
"A terminal refresh did not preserve existing slots or append a new resource");
layout.update(List.of(dirt, diamond), true);
require(ItemStack.isSameItemSameComponents(layout.page(0).get(0).stack(), dirt.stack()),
"A deliberate terminal layout reset did not compact the visible resources");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
+99 -17
View File
@@ -68,6 +68,22 @@ tasks.register("questRequestModelSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestModelSmoke"
}
tasks.register("questTrackerModelSmoke", JavaExec) {
group = "verification"
description = "Checks the bounded quest-tracker snapshot used by the HUD concept."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerModelSmoke"
}
tasks.register("questIdSuggestionsSmoke", JavaExec) {
group = "verification"
description = "Checks accent-folded quest-panel item name ranking and inventory pins."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestionsSmoke"
}
tasks.register("shopHeaderLayoutSmoke", JavaExec) {
group = "verification"
description = "Checks that the daily Shop timer and unlocked-offer status cannot overlap."
@@ -92,6 +108,66 @@ tasks.register("capeJoinSyncSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeJoinSyncQueueSmoke"
}
tasks.register("capeBannerAppearanceSmoke", JavaExec) {
group = "verification"
description = "Checks the immutable 16-layer bound used by customizable banner capes."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearanceSmoke"
}
tasks.register("verifyBannerCapes") {
group = "verification"
description = "Checks the white-cape recipe, translations, bounded sync and vanilla-physics renderer."
inputs.files(
file("src/main/resources/data/sanctuary/recipe/banner_cape.json"),
file("src/main/resources/assets/sanctuary/models/item/white_cape.json"),
file("src/main/resources/assets/sanctuary/items/white_cape.json"),
file("src/main/resources/assets/sanctuary/textures/entity/cape/white_cape.png"),
file("src/main/resources/assets/sanctuary/textures/item/cape/white_cape.png"),
file("src/main/resources/fabric.mod.json")
)
inputs.files(fileTree("src/main/java").matching {
include "**/CapeBannerAppearance.java", "**/CapeSyncPayload.java", "**/SanctuaryCapes.java",
"**/BannerCapeLayer.java", "**/BannerCapeModel.java", "**/AvatarRendererMixin.java"
})
inputs.files(["en_us", "fr_fr", "ru_ru"].collect {
file("src/main/resources/assets/sanctuary/lang/${it}.json")
})
doLast {
def recipe = new JsonSlurper().parse(file(
"src/main/resources/data/sanctuary/recipe/banner_cape.json"))
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
def javaText = fileTree("src/main/java").matching { include "**/*.java" }.files*.text.join("\n")
def entityTexture = ImageIO.read(file(
"src/main/resources/assets/sanctuary/textures/entity/cape/white_cape.png"))
def itemTexture = ImageIO.read(file(
"src/main/resources/assets/sanctuary/textures/item/cape/white_cape.png"))
def translations = ["en_us", "fr_fr", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.126"
|| manifest.custom?.sanctuary26?.data_version != 11
|| manifest.custom?.sanctuary26?.network_protocol != 23
|| recipe.type != "minecraft:crafting_special_shielddecoration"
|| recipe.banner != "#minecraft:banners"
|| recipe.target != "sanctuary:white_cape"
|| recipe.result?.id != "sanctuary:white_cape"
|| entityTexture?.width != 64 || entityTexture?.height != 32
|| itemTexture?.width != 16 || itemTexture?.height != 16
|| translations.any { !it.containsKey("item.sanctuary.white_cape") }
|| !javaText.contains('SanctuaryMod.id("white_cape")')
|| !javaText.contains("DataComponents.BANNER_PATTERNS")
|| !javaText.contains("MAX_PATTERNS = 16")
|| !javaText.contains("ByteBufCodecs.list(CapeBannerAppearance.MAX_PATTERNS)")
|| !javaText.contains("BannerRenderer.submitPatterns")
|| !javaText.contains("LivingEntityRenderLayerRegistrationCallback.EVENT.register")
|| !javaText.contains("extends PlayerCapeModel")) {
throw new GradleException("Sanctuary customizable banner-cape contract is incomplete")
}
}
}
tasks.register("alphaIndevGeneratorSmoke", JavaExec) {
group = "verification"
description = "Checks deterministic finite Indev islands, residences and safe portal arrivals."
@@ -135,7 +211,7 @@ tasks.register("verifyBlackMarket") {
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
@@ -149,7 +225,7 @@ tasks.register("verifyBlackMarket") {
|| !minecraftMixin.contains('method = "pickBlockOrEntity"')
|| !middleClickPayload.contains('SanctuaryMod.id("c2s/black_market_interact")')
|| !middleClickPayload.contains("No client position, listing ID, item or price is trusted")
|| !network.contains("PROTOCOL_VERSION = 22")
|| !network.contains("PROTOCOL_VERSION = 23")
|| !network.contains("BlackMarketService.middleClick(context.player())")
|| !service.contains("loadedDeliveryBox") || !service.contains("PARCEL_RETENTION_MILLIS")
|| !policy.contains("DataComponents.CONTAINER") || !policy.contains("DataComponents.BUNDLE_CONTENTS")
@@ -278,7 +354,9 @@ tasks.register("verifySanctuary") {
inputs.files(fileTree("tools"))
dependsOn tasks.named("progressionModelSmoke"), tasks.named("serverIdentityModelSmoke"),
tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"),
tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"),
tasks.named("questBoardModelSmoke"), tasks.named("questTrackerModelSmoke"),
tasks.named("questIdSuggestionsSmoke"),
tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"),
tasks.named("capeJoinSyncSmoke"),
tasks.named("verifyBlackMarket")
@@ -1126,7 +1204,7 @@ tasks.register("verifySanctuary") {
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (manifest.custom?.sanctuary26?.data_version != 11
|| manifest.custom?.sanctuary26?.network_protocol != 22
|| manifest.custom?.sanctuary26?.network_protocol != 23
|| manifest.depends?.jei != ">=30.24.0.176"
|| manifest.depends?."fabric-api" != ">=0.155.0+26.2"
|| !manifest.entrypoints?.client?.contains("fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient")
@@ -2832,7 +2910,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
def shop = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|| migration.shop_currency_inventory?.overflow_rows_counted != true
@@ -2998,7 +3076,7 @@ tasks.register("verifyProgressivePauseMenu") {
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.pack_version != "26.2.0-alpha.191"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|| migration.pause_menu?.progression_full_width != true
@@ -3036,7 +3114,7 @@ tasks.register("verifyShopPricingLayout") {
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|| migration.shop_layout?.locked_offer_buttons != 6
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
@@ -3067,7 +3145,7 @@ tasks.register("verifyShopCsvBossGates") {
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3098,7 +3176,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3126,7 +3204,7 @@ tasks.register("verifyInventoryShiftClick") {
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
def gui = file("INVENTORY_GUI.md").text
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3172,7 +3250,7 @@ tasks.register("verifyQuestRequests") {
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
.pools[0].entries[0].name
}
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3184,7 +3262,7 @@ tasks.register("verifyQuestRequests") {
|| !data.contains('SAVE_KEY = "quest_requests"') || !data.contains("DATA_VERSION = 3")
|| !service.contains("PlayerBlockBreakEvents.BEFORE")
|| !service.contains("player.blockPosition().distSqr(payload.pos()) > 64.0D")
|| !network.contains("PROTOCOL_VERSION = 22")
|| !network.contains("PROTOCOL_VERSION = 23")
|| !network.contains("QuestRequestSubmitPayload.ID")
|| !network.contains("QuestRequestAcceptPayload.ID")
|| !registry.contains('SanctuaryMod.id("quest_request_sign")')
@@ -3230,7 +3308,7 @@ tasks.register("verifyQuestRequestContracts") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -3250,7 +3328,7 @@ tasks.register("verifyQuestRequestContracts") {
|| !service.contains('REWARD_LOAN = "loan"')
|| !service.contains('PLAYER.equals(targetKind)')
|| !screen.contains("rewardQuantity") || !screen.contains("killTargetIndex")
|| !network.contains("PROTOCOL_VERSION = 22")
|| !network.contains("PROTOCOL_VERSION = 23")
|| standingModel.textures?.all != "sanctuary:block/pink_quest_request_sign"
|| itemModel.textures?.layer0 != "sanctuary:item/pink_quest_request_sign"
|| blockTexture == null || blockTexture.width != 32 || blockTexture.height != 32
@@ -3299,7 +3377,7 @@ tasks.register("verifyQuestRequestEscrow") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -3320,7 +3398,7 @@ tasks.register("verifyQuestRequestEscrow") {
|| !service.contains('"quest_request_reward"')
|| !service.contains('"quest_request_delivery"')
|| service.contains("sanctuary_quest_accept")
|| !network.contains("PROTOCOL_VERSION = 22")
|| !network.contains("PROTOCOL_VERSION = 23")
|| !network.contains("QuestRequestAcceptPayload.ID")
|| !network.contains("QuestRequestConfirmPayload.ID")
|| !acceptPayload.contains("readUtf(36)")
@@ -3371,7 +3449,7 @@ tasks.register("verifyAlphaIndev") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.122"
if (project.version.toString() != "0.0.0-alpha.126"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -3395,6 +3473,8 @@ tasks.register("verifyAlphaIndev") {
tasks.named("check") {
dependsOn tasks.named("verifySanctuary")
dependsOn tasks.named("verifyBannerCapes")
dependsOn tasks.named("capeBannerAppearanceSmoke")
dependsOn tasks.named("verifyDeliveryBoxOrientation")
dependsOn tasks.named("verifyQuestRequests")
dependsOn tasks.named("verifyQuestRequestContracts")
@@ -3402,6 +3482,8 @@ tasks.named("check") {
dependsOn tasks.named("verifyShopCurrencyRowsRelease")
dependsOn tasks.named("celestialGraphSmoke")
dependsOn tasks.named("questRequestModelSmoke")
dependsOn tasks.named("questTrackerModelSmoke")
dependsOn tasks.named("questIdSuggestionsSmoke")
dependsOn tasks.named("alphaIndevGeneratorSmoke")
dependsOn tasks.named("verifyAlphaIndev")
}
@@ -0,0 +1,70 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearance;
import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityFeatureRenderEvents;
import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityRenderLayerRegistrationCallback;
import net.minecraft.client.model.player.PlayerModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BannerRenderer;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.client.resources.model.EquipmentAssetManager;
import net.minecraft.client.resources.model.EquipmentClientInfo;
import net.minecraft.client.resources.model.sprite.SpriteGetter;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.equipment.Equippable;
/** Renders the white cape with the base color and bounded pattern layers copied from a banner. */
public final class BannerCapeLayer extends RenderLayer<AvatarRenderState, PlayerModel> {
private final BannerCapeModel model = new BannerCapeModel();
private final SpriteGetter sprites;
private final EquipmentAssetManager equipmentAssets;
private BannerCapeLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent,
SpriteGetter sprites, EquipmentAssetManager equipmentAssets) {
super(parent);
this.sprites = sprites;
this.equipmentAssets = equipmentAssets;
}
public static void register() {
LivingEntityRenderLayerRegistrationCallback.EVENT.register((entityType, renderer, helper, context) -> {
if (entityType != EntityTypes.PLAYER || !(renderer instanceof AvatarRenderer<?>)) return;
@SuppressWarnings("unchecked")
RenderLayerParent<AvatarRenderState, PlayerModel> parent =
(RenderLayerParent<AvatarRenderState, PlayerModel>) (Object) renderer;
helper.register(new BannerCapeLayer(parent, context.getSprites(), context.getEquipmentAssets()));
});
LivingEntityFeatureRenderEvents.ALLOW_CAPE_RENDER.register(state ->
!((BannerCapeRenderState) (Object) state).sanctuary$hasBannerCape());
}
@Override
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int light,
AvatarRenderState state, float yRot, float xRot) {
CapeBannerAppearance appearance =
((BannerCapeRenderState) (Object) state).sanctuary$bannerCape();
if (appearance == null || state.isInvisible || !state.showCape
|| hasLayer(state.chestEquipment, EquipmentClientInfo.LayerType.WINGS)) return;
poseStack.pushPose();
if (hasLayer(state.chestEquipment, EquipmentClientInfo.LayerType.HUMANOID)) {
poseStack.translate(0.0F, -0.053125F, 0.06875F);
}
BannerRenderer.submitPatterns(sprites, poseStack, collector, light, OverlayTexture.NO_OVERLAY,
model, state, true, appearance.baseColor(), appearance.patterns(), null);
poseStack.popPose();
}
private boolean hasLayer(ItemStack stack, EquipmentClientInfo.LayerType layerType) {
Equippable equippable = stack.get(DataComponents.EQUIPPABLE);
if (equippable == null || equippable.assetId().isEmpty()) return false;
return !equipmentAssets.get(equippable.assetId().orElseThrow()).getLayers(layerType).isEmpty();
}
}
@@ -0,0 +1,39 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.model.geom.builders.PartDefinition;
import net.minecraft.client.model.player.PlayerCapeModel;
import net.minecraft.client.model.player.PlayerModel;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
/** Cape geometry whose UV area matches a 20x40 vanilla banner, scaled to cape dimensions. */
final class BannerCapeModel extends PlayerCapeModel {
private final ModelPart bannerCape;
BannerCapeModel() {
super(createLayer().bakeRoot());
bannerCape = body.getChild("cape");
}
@Override
public void setupAnim(AvatarRenderState state) {
super.setupAnim(state);
bannerCape.xScale = 0.5F;
bannerCape.yScale = 0.4F;
}
private static LayerDefinition createLayer() {
MeshDefinition mesh = PlayerModel.createMesh(net.minecraft.client.model.geom.builders.CubeDeformation.NONE,
false);
PartDefinition root = mesh.getRoot().clearRecursively();
PartDefinition body = root.getChild("body");
body.addOrReplaceChild("cape", CubeListBuilder.create().texOffs(0, 0)
.addBox(-10.0F, 0.0F, -1.0F, 20.0F, 40.0F, 1.0F),
PartPose.offsetAndRotation(0.0F, 0.0F, 2.0F, 0.0F, (float) Math.PI, 0.0F));
return LayerDefinition.create(mesh, 64, 64);
}
}
@@ -0,0 +1,13 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearance;
/** Client-only extension carrying one extracted banner cape appearance into render layers. */
public interface BannerCapeRenderState {
CapeBannerAppearance sanctuary$bannerCape();
void sanctuary$setBannerCape(CapeBannerAppearance appearance);
default boolean sanctuary$hasBannerCape() {
return sanctuary$bannerCape() != null;
}
}
@@ -1,14 +1,29 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestSubmitPayload;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestions;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
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.EditBox;
import net.minecraft.client.gui.components.events.GuiEventListener;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import org.lwjgl.glfw.GLFW;
/** Complete bounded editor for mission target and promised reward contracts. */
public final class QuestRequestEditorScreen extends Screen {
@@ -19,8 +34,12 @@ public final class QuestRequestEditorScreen extends Screen {
QuestRequestService.REWARD_GIFT, QuestRequestService.REWARD_PAYMENT,
QuestRequestService.REWARD_LOAN
};
private static final int SUGGESTION_ROW = 16;
private static final int SUGGESTION_MARGIN = 4;
private final Screen parent;
private final QuestRequestEditorPayload payload;
private final List<QuestIdSuggestions.Entry> itemCatalog = new ArrayList<>();
private final List<QuestIdSuggestions.Entry> mobCatalog = new ArrayList<>();
private EditBox alias;
private EditBox objective;
private EditBox target;
@@ -34,6 +53,10 @@ public final class QuestRequestEditorScreen extends Screen {
private int actionIndex;
private int rewardIndex;
private int killTargetIndex;
private int selectedSuggestion;
private boolean applyingCompletion;
private boolean suggestionsOpen;
private List<QuestIdSuggestions.Match> suggestions = List.of();
public QuestRequestEditorScreen(Screen parent, QuestRequestEditorPayload payload) {
super(Component.translatable("screen.sanctuary.quest_request.title"));
@@ -51,6 +74,7 @@ public final class QuestRequestEditorScreen extends Screen {
@Override
protected void init() {
int center = width / 2;
fillCatalogs();
alias = addRenderableWidget(new EditBox(font, center - 152, 48, 304, 20,
Component.translatable("screen.sanctuary.quest_request.alias")));
alias.setMaxLength(QuestRequestService.MAX_ALIAS_LENGTH);
@@ -80,15 +104,17 @@ public final class QuestRequestEditorScreen extends Screen {
quantity.setMaxLength(4);
quantity.setValue(Integer.toString(payload.quantity()));
target = addRenderableWidget(new EditBox(font, center + 52, 118, 100, 20,
target = addRenderableWidget(new EditBox(font, center + 70, 118, 82, 20,
Component.translatable("screen.sanctuary.quest_request.target")));
target.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH);
target.setValue(payload.target());
target.setResponder(value -> onIdTyped());
reward = addRenderableWidget(Button.builder(rewardLabel(), button -> {
rewardIndex = (rewardIndex + 1) % REWARDS.length;
button.setMessage(rewardLabel());
rewardTarget.setHint(rewardHint());
closeSuggestions();
}).bounds(center - 152, 157, 84, 20).build());
rewardQuantity = addRenderableWidget(new EditBox(font, center - 64, 157, 46, 20,
@@ -96,11 +122,12 @@ public final class QuestRequestEditorScreen extends Screen {
rewardQuantity.setMaxLength(4);
rewardQuantity.setValue(Integer.toString(payload.rewardQuantity()));
rewardTarget = addRenderableWidget(new EditBox(font, center - 14, 157, 166, 20,
rewardTarget = addRenderableWidget(new EditBox(font, center + 8, 157, 144, 20,
Component.translatable("screen.sanctuary.quest_request.reward_target")));
rewardTarget.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH);
rewardTarget.setValue(payload.rewardTarget());
rewardTarget.setHint(rewardHint());
rewardTarget.setResponder(value -> onIdTyped());
submit = addRenderableWidget(Button.builder(Component.translatable("screen.sanctuary.quest_request.publish"),
button -> submit()).bounds(center - 152, 199, 149, 20).build());
@@ -112,9 +139,16 @@ public final class QuestRequestEditorScreen extends Screen {
@Override
public void tick() {
if (suggestionsOpen) refreshSuggestions();
submit.active = values() != null;
}
@Override
public void setFocused(GuiEventListener listener) {
super.setFocused(listener);
if (listener != target && listener != rewardTarget) closeSuggestions();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
int center = width / 2;
@@ -129,9 +163,53 @@ public final class QuestRequestEditorScreen extends Screen {
center - 152, 107, 0xFFDBB7CA);
graphics.text(font, Component.translatable("screen.sanctuary.quest_request.reward"),
center - 152, 146, 0xFFF2C4D7);
graphics.centeredText(font, Component.translatable("screen.sanctuary.quest_request.help"),
center, 183, 0xFF9F8B95);
graphics.centeredText(font, Component.translatable(idFieldFocused()
? "screen.sanctuary.quest_request.id_help"
: "screen.sanctuary.quest_request.help"), center, 183, 0xFF9F8B95);
drawPreview(graphics, target, targetPreview(mouseX, mouseY), center + 52, 120);
drawPreview(graphics, rewardTarget, rewardPreview(mouseX, mouseY), center - 14, 159);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
renderSuggestions(graphics, mouseX, mouseY);
}
@Override
public boolean keyPressed(KeyEvent event) {
if (!suggestions.isEmpty()) {
if (event.key() == GLFW.GLFW_KEY_DOWN) {
selectedSuggestion = (selectedSuggestion + 1) % suggestions.size();
return true;
}
if (event.key() == GLFW.GLFW_KEY_UP) {
selectedSuggestion = (selectedSuggestion + suggestions.size() - 1) % suggestions.size();
return true;
}
}
return super.keyPressed(event);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
int index = suggestionIndexAt(event.x(), event.y());
if (index >= 0) {
applySuggestion(index);
return true;
}
boolean handled = super.mouseClicked(event, doubleClick);
if (clickedSearchField(event.x(), event.y())) {
suggestionsOpen = true;
refreshSuggestions();
} else closeSuggestions();
return handled;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (!suggestions.isEmpty() && suggestionPanel().contains(mouseX, mouseY)) {
int delta = scrollY > 0 ? -1 : 1;
selectedSuggestion = Math.floorMod(selectedSuggestion + delta, suggestions.size());
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
private void refreshMissionControls() {
@@ -139,6 +217,7 @@ public final class QuestRequestEditorScreen extends Screen {
targetKind.setMessage(targetKindLabel());
targetKind.active = QuestRequestService.KILL.equals(currentAction());
target.setHint(targetHint());
closeSuggestions();
}
private String currentAction() { return ACTIONS[actionIndex]; }
@@ -172,14 +251,16 @@ public final class QuestRequestEditorScreen extends Screen {
}
private Values values() {
if (objective.getValue().isBlank() || target.getValue().isBlank()
|| rewardTarget.getValue().isBlank()) return null;
if (objective.getValue().isBlank()) return null;
String resolvedTarget = resolveTarget();
String resolvedReward = QuestIdSuggestions.resolve(itemCatalog, rewardTarget.getValue(), inventoryIds());
if (resolvedTarget == null || resolvedReward == null) return null;
try {
int amount = Integer.parseInt(quantity.getValue());
int rewardAmount = Integer.parseInt(rewardQuantity.getValue());
if (amount < 1 || amount > QuestRequestService.MAX_QUANTITY
|| rewardAmount < 1 || rewardAmount > QuestRequestService.MAX_QUANTITY) return null;
return new Values(amount, rewardAmount);
return new Values(resolvedTarget, amount, resolvedReward, rewardAmount);
} catch (NumberFormatException exception) {
return null;
}
@@ -189,13 +270,232 @@ public final class QuestRequestEditorScreen extends Screen {
Values values = values();
if (values == null || !ClientPlayNetworking.canSend(QuestRequestSubmitPayload.ID)) return;
ClientPlayNetworking.send(new QuestRequestSubmitPayload(payload.pos(), alias.getValue(),
objective.getValue(), currentAction(), currentTargetKind(), target.getValue(), values.quantity(),
currentReward(), rewardTarget.getValue(), values.rewardQuantity()));
objective.getValue(), currentAction(), currentTargetKind(), values.target(), values.quantity(),
currentReward(), values.rewardTarget(), values.rewardQuantity()));
onClose();
}
private void fillCatalogs() {
itemCatalog.clear();
for (Item item : BuiltInRegistries.ITEM) {
if (item == Items.AIR) continue;
Identifier id = BuiltInRegistries.ITEM.getKey(item);
itemCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(),
new ItemStack(item).getHoverName().getString()));
}
mobCatalog.clear();
for (var type : BuiltInRegistries.ENTITY_TYPE) {
Identifier id = BuiltInRegistries.ENTITY_TYPE.getKey(type);
if ("minecraft:player".equals(id.toString())) continue;
mobCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(),
type.getDescription().getString()));
}
}
private void onIdTyped() {
if (applyingCompletion) return;
suggestionsOpen = !focusedSearchValue().isBlank();
refreshSuggestions();
}
private void closeSuggestions() {
suggestionsOpen = false;
suggestions = List.of();
selectedSuggestion = 0;
}
private void refreshSuggestions() {
if (applyingCompletion || !suggestionsOpen) {
if (!suggestionsOpen) suggestions = List.of();
return;
}
EditBox field = focusedIdField();
if (field == null || field.getValue().isBlank()) {
closeSuggestions();
return;
}
boolean itemField = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind());
List<QuestIdSuggestions.Match> next = QuestIdSuggestions.suggest(
itemField ? itemCatalog : mobCatalog, field.getValue(),
itemField ? inventoryIds() : Set.of(), QuestIdSuggestions.LIMIT);
if (!next.equals(suggestions)) {
suggestions = next;
selectedSuggestion = 0;
} else if (selectedSuggestion >= suggestions.size()) {
selectedSuggestion = 0;
}
}
private void applySuggestion(int index) {
EditBox field = focusedIdField();
if (field == null || index < 0 || index >= suggestions.size()) return;
writeId(field, suggestions.get(index).id());
closeSuggestions();
}
private boolean clickedSearchField(double mouseX, double mouseY) {
if (contains(rewardTarget, mouseX, mouseY)) return true;
return contains(target, mouseX, mouseY)
&& !QuestRequestService.PLAYER.equals(currentTargetKind());
}
private boolean contains(EditBox field, double mouseX, double mouseY) {
return field != null && mouseX >= field.getX() && mouseX < field.getX() + field.getWidth()
&& mouseY >= field.getY() && mouseY < field.getY() + field.getHeight();
}
private String focusedSearchValue() {
EditBox field = focusedIdField();
return field == null ? "" : field.getValue();
}
private void writeId(EditBox field, String id) {
applyingCompletion = true;
field.setValue(id);
field.moveCursorToEnd(false);
applyingCompletion = false;
}
private String resolveTarget() {
if (QuestRequestService.PLAYER.equals(currentTargetKind())) {
String name = target.getValue().strip();
return name.matches("[A-Za-z0-9_]{1,16}") ? name : null;
}
boolean item = QuestRequestService.ITEM.equals(currentTargetKind());
return QuestIdSuggestions.resolve(item ? itemCatalog : mobCatalog, target.getValue(),
item ? inventoryIds() : Set.of());
}
private QuestIdSuggestions.Match targetPreview(int mouseX, int mouseY) {
if (QuestRequestService.PLAYER.equals(currentTargetKind())) return null;
return previewFor(target, mouseX, mouseY);
}
private QuestIdSuggestions.Match rewardPreview(int mouseX, int mouseY) {
return previewFor(rewardTarget, mouseX, mouseY);
}
private QuestIdSuggestions.Match previewFor(EditBox field, int mouseX, int mouseY) {
if (field == null) return null;
if (field.isFocused() && !suggestions.isEmpty()) {
int hover = suggestionIndexAt(mouseX, mouseY);
return suggestions.get(hover >= 0 ? hover : selectedSuggestion);
}
boolean item = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind());
return QuestIdSuggestions.exact(item ? itemCatalog : mobCatalog, field.getValue(),
item ? inventoryIds() : Set.of());
}
private void drawPreview(GuiGraphicsExtractor graphics, EditBox field,
QuestIdSuggestions.Match match, int x, int y) {
if (field == null || match == null) return;
graphics.fill(x, y, x + 16, y + 16, 0x66101010);
graphics.outline(x, y, 16, 16, 0x88E8A8C3);
ItemStack stack = iconFor(match.id(), field == rewardTarget
|| QuestRequestService.ITEM.equals(currentTargetKind()));
if (!stack.isEmpty()) graphics.item(stack, x, y);
}
private void renderSuggestions(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
if (suggestions.isEmpty()) return;
Panel panel = suggestionPanel();
graphics.fill(panel.x(), panel.y(), panel.x() + panel.width(), panel.y() + panel.height(), 0xF2181417);
graphics.outline(panel.x(), panel.y(), panel.width(), panel.height(), 0xFFE8A8C3);
boolean itemField = focusedIdField() == rewardTarget
|| QuestRequestService.ITEM.equals(currentTargetKind());
int hover = suggestionIndexAt(mouseX, mouseY);
for (int index = 0; index < suggestions.size(); index++) {
QuestIdSuggestions.Match match = suggestions.get(index);
int top = panel.y() + 1 + index * SUGGESTION_ROW;
if (index == selectedSuggestion || index == hover) {
graphics.fill(panel.x() + 1, top, panel.x() + panel.width() - 1, top + SUGGESTION_ROW, 0x55E8A8C3);
}
ItemStack stack = iconFor(match.id(), itemField);
if (!stack.isEmpty()) graphics.item(stack, panel.x() + 2, top);
graphics.text(font, Component.literal(ellipsize(match.displayName(), panel.width() - 24)),
panel.x() + 20, top + 4, 0xFFF2C4D7);
}
}
private ItemStack iconFor(String id, boolean item) {
Identifier identifier = Identifier.tryParse(id);
if (identifier == null) return ItemStack.EMPTY;
if (item) {
return BuiltInRegistries.ITEM.getOptional(identifier).map(ItemStack::new).orElse(ItemStack.EMPTY);
}
Identifier egg = Identifier.fromNamespaceAndPath(identifier.getNamespace(),
identifier.getPath() + "_spawn_egg");
return BuiltInRegistries.ITEM.getOptional(egg).map(ItemStack::new).orElse(ItemStack.EMPTY);
}
private Set<String> inventoryIds() {
Set<String> ids = new LinkedHashSet<>();
if (minecraft == null || minecraft.player == null) return ids;
ItemStack held = minecraft.player.getMainHandItem();
if (!held.isEmpty()) ids.add(BuiltInRegistries.ITEM.getKey(held.getItem()).toString());
for (var slot : SanctuaryPlayerInventoryView.unlockedRows(minecraft.player)) {
if (!slot.stack().isEmpty()) {
ids.add(BuiltInRegistries.ITEM.getKey(slot.stack().getItem()).toString());
}
}
return ids;
}
private EditBox focusedIdField() {
if (target != null && target.isFocused() && !QuestRequestService.PLAYER.equals(currentTargetKind())) {
return target;
}
if (rewardTarget != null && rewardTarget.isFocused()) return rewardTarget;
return null;
}
private boolean idFieldFocused() {
return focusedIdField() != null;
}
private Panel suggestionPanel() {
EditBox field = focusedIdField();
if (field == null) return new Panel(0, 0, 0, 0);
int rows = Math.max(1, suggestions.size());
int height = rows * SUGGESTION_ROW + 2;
int width = 200;
int x = Math.max(SUGGESTION_MARGIN, Math.min(this.width / 2 - width / 2,
this.width - width - SUGGESTION_MARGIN));
int below = field.getY() + field.getHeight() + 2;
int above = field.getY() - 2 - height;
boolean preferAbove = field == rewardTarget;
int y = preferAbove && above >= SUGGESTION_MARGIN ? above : below;
if (y + height > this.height - SUGGESTION_MARGIN) y = above;
y = Math.max(SUGGESTION_MARGIN, Math.min(y, this.height - height - SUGGESTION_MARGIN));
return new Panel(x, y, width, height);
}
private int suggestionIndexAt(double mouseX, double mouseY) {
if (suggestions.isEmpty()) return -1;
Panel panel = suggestionPanel();
if (!panel.contains(mouseX, mouseY)) return -1;
int index = (int) ((mouseY - panel.y() - 1) / SUGGESTION_ROW);
return index >= 0 && index < suggestions.size() ? index : -1;
}
private String ellipsize(String value, int maxWidth) {
if (font.width(value) <= maxWidth) return value;
String suffix = "";
int suffixWidth = font.width(suffix);
StringBuilder cut = new StringBuilder(value);
while (cut.length() > 0 && font.width(cut.toString()) + suffixWidth > maxWidth) {
cut.deleteCharAt(cut.length() - 1);
}
return cut + suffix;
}
@Override public void onClose() { if (minecraft != null) minecraft.gui.setScreen(parent); }
@Override public boolean isPauseScreen() { return false; }
private record Values(int quantity, int rewardQuantity) {}
private record Values(String target, int quantity, String rewardTarget, int rewardQuantity) {}
private record Panel(int x, int y, int width, int height) {
boolean contains(double mouseX, double mouseY) {
return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height;
}
}
}
@@ -0,0 +1,81 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerSync;
import java.util.List;
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** Transparent, toggleable text overlay for accepted quests. */
final class QuestTrackerHud {
private static final int MAX_WIDTH = 240;
private static final int MARGIN = 2;
private static final int ENTRY_SPACING = 2;
private static final int BODY = 0xFFE9DDE2;
private QuestTrackerHud() {}
static void register() {
HudElementRegistry.addLast(SanctuaryMod.id("quest_tracker"), QuestTrackerHud::render);
}
private static void render(GuiGraphicsExtractor graphics, DeltaTracker tickCounter) {
Minecraft client = Minecraft.getInstance();
if (client.player == null || client.level == null || client.gui.screen() != null) return;
if (!SanctuaryClientQuests.visible()) return;
List<QuestTrackerSnapshotPayload.Entry> entries = SanctuaryClientQuests.entries();
if (entries.isEmpty()) return;
int width = Math.max(1, Math.min(MAX_WIDTH, graphics.guiWidth() - MARGIN * 2));
int y = MARGIN;
for (QuestTrackerSnapshotPayload.Entry entry : entries) {
drawEntry(graphics, client.font, entry, MARGIN, y, width);
y += client.font.lineHeight * 2 + ENTRY_SPACING;
}
}
private static void drawEntry(GuiGraphicsExtractor graphics, Font font,
QuestTrackerSnapshotPayload.Entry entry, int x, int y, int width) {
int accent = 0xFF000000 | (entry.accent() & 0xFFFFFF);
graphics.text(font, ellipsize(font, title(entry), width), x, y, accent, true);
Component progress = Component.translatable("hud.sanctuary.quest_tracker.progress",
action(entry), describeTarget(entry), entry.progress(), entry.required());
graphics.text(font, ellipsize(font, progress, width), x, y + font.lineHeight, BODY, true);
}
private static Component title(QuestTrackerSnapshotPayload.Entry entry) {
if (!entry.title().isBlank()) return Component.literal(entry.title());
return Component.translatable("hud.sanctuary.quest_tracker.source." + entry.source());
}
private static Component action(QuestTrackerSnapshotPayload.Entry entry) {
if (QuestTrackerSync.SOURCE_BOARD.equals(entry.source())) {
return Component.translatable("sign.sanctuary.quest_board.action." + entry.action());
}
return Component.translatable("message.sanctuary.quest_request.action." + entry.action());
}
private static Component describeTarget(QuestTrackerSnapshotPayload.Entry entry) {
Identifier id = Identifier.tryParse(entry.target());
if (id == null) return Component.literal(entry.target());
return BuiltInRegistries.ENTITY_TYPE.getOptional(id).map(type -> type.getDescription())
.or(() -> BuiltInRegistries.ITEM.getOptional(id).map(item -> new ItemStack(item).getHoverName()))
.orElse(Component.literal(id.getPath()));
}
private static Component ellipsize(Font font, Component text, int maxWidth) {
if (font.width(text) <= maxWidth) return text;
String value = text.getString();
while (!value.isEmpty() && font.width(value + "") > maxWidth) {
value = value.substring(0, value.length() - 1);
}
return Component.literal(value + "");
}
}
@@ -14,6 +14,7 @@ import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketEditorPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketInteractPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestConfirmPayload;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionCosts;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryUnlock;
import net.fabricmc.api.ClientModInitializer;
@@ -48,6 +49,7 @@ public final class SanctuaryClient implements ClientModInitializer {
private static KeyMapping zoomKey;
private static KeyMapping celestialKey;
private static KeyMapping switchHotbarRowKey;
private static KeyMapping questTrackerKey;
private static boolean zoomWasHeld;
private static boolean smokeScreenOpened;
private static boolean inventorySmokeScreenOpened;
@@ -91,10 +93,18 @@ public final class SanctuaryClient implements ClientModInitializer {
GLFW.GLFW_KEY_TAB,
SANCTUARY_CATEGORY
));
questTrackerKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.sanctuary.quest_tracker",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_J,
SANCTUARY_CATEGORY
));
SanctuaryAtlasOptions.load();
SanctuaryClientQuests.load();
SanctuaryBulkActionsClient.initialize();
SanctuaryHealthPreview.register();
CelestialSpyglassSelection.register();
BannerCapeLayer.register();
EntityRendererRegistry.register(SanctuaryRegistries.SEAT, NoopRenderer::new);
ClientTickEvents.END_CLIENT_TICK.register(SanctuaryClient::tick);
ClientPlayNetworking.registerGlobalReceiver(ProgressionSnapshotPayload.ID, (payload, context) ->
@@ -124,6 +134,8 @@ public final class SanctuaryClient implements ClientModInitializer {
ClientPlayNetworking.registerGlobalReceiver(QuestRequestConfirmPayload.ID, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new QuestRequestConfirmScreen(context.client().gui.screen(), payload))));
ClientPlayNetworking.registerGlobalReceiver(QuestTrackerSnapshotPayload.ID, (payload, context) ->
context.client().execute(() -> SanctuaryClientQuests.update(payload)));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
SanctuaryAtlasVisitedChunks.saveIfDirty(client);
SanctuaryAtlasMapCache.clear();
@@ -135,6 +147,7 @@ public final class SanctuaryClient implements ClientModInitializer {
CelestialSpyglassSelection.reset();
SanctuaryClientShop.clear();
SanctuaryClientCapes.clear();
SanctuaryClientQuests.clear();
zoomWasHeld = false;
});
ClientHotbarScrollEvents.ALLOW.register((inventory, selectedSlot, scrollDirection, horizontalAmount, verticalAmount) -> {
@@ -142,6 +155,7 @@ public final class SanctuaryClient implements ClientModInitializer {
return !SanctuaryAtlasHud.handleScroll(amount);
});
SanctuaryAtlasHud.register();
QuestTrackerHud.register();
SanctuaryMod.LOGGER.info("[{}] Atlas ready on M; Mouse 5 controls the HUD preview.", SanctuaryMod.DISPLAY_NAME);
}
@@ -181,6 +195,10 @@ public final class SanctuaryClient implements ClientModInitializer {
}
}
while (openAtlasKey.consumeClick()) toggleAtlas(client);
while (questTrackerKey.consumeClick()) {
if (client.player != null && client.level != null && client.gui.screen() == null)
toggleQuestTracker(client);
}
while (celestialKey.consumeClick()) {
if (client.player != null && client.level != null && client.gui.screen() == null)
client.gui.setScreen(new CelestialSkyScreen(null));
@@ -275,6 +293,14 @@ public final class SanctuaryClient implements ClientModInitializer {
&& client.level != null && client.gui.screen() == null;
}
private static void toggleQuestTracker(Minecraft client) {
boolean visible = SanctuaryClientQuests.toggle();
if (client.player == null) return;
client.player.sendOverlayMessage(Component.translatable(visible
? "hud.sanctuary.quest_tracker.toggle_on"
: "hud.sanctuary.quest_tracker.toggle_off"));
}
private static void toggleAtlas(Minecraft client) {
if (client.gui.screen() instanceof SanctuaryAtlasScreen) {
client.gui.setScreen(null);
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapes;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearance;
import fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayload;
import java.util.HashMap;
import java.util.Map;
@@ -9,22 +10,31 @@ import net.minecraft.resources.Identifier;
/** Client mirror of server-authoritative equipped cape choices. */
public final class SanctuaryClientCapes {
private static final Map<UUID, Identifier> TEXTURES = new HashMap<>();
private static final Map<UUID, Visual> VISUALS = new HashMap<>();
private SanctuaryClientCapes() {
}
public static void update(CapeSyncPayload payload) {
Identifier texture = SanctuaryCapes.texture(payload.capeItemId());
if (texture == null) TEXTURES.remove(payload.playerId());
else TEXTURES.put(payload.playerId(), texture);
CapeBannerAppearance banner = SanctuaryCapes.isBannerCape(payload.capeItemId())
? new CapeBannerAppearance(payload.baseColor(), payload.bannerPatterns()) : null;
if (texture == null) VISUALS.remove(payload.playerId());
else VISUALS.put(payload.playerId(), new Visual(texture, banner));
}
public static Identifier texture(UUID playerId) {
return TEXTURES.get(playerId);
Visual visual = VISUALS.get(playerId);
return visual == null ? null : visual.texture();
}
public static Visual visual(UUID playerId) {
return VISUALS.get(playerId);
}
public static void clear() {
TEXTURES.clear();
VISUALS.clear();
}
public record Visual(Identifier texture, CapeBannerAppearance banner) {}
}
@@ -0,0 +1,79 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import net.fabricmc.loader.api.FabricLoader;
/** Client-side quest tracker snapshot and visibility preference. */
final class SanctuaryClientQuests {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path FILE = FabricLoader.getInstance().getConfigDir()
.resolve("sanctuary-quest-tracker.json");
private static Settings settings = new Settings();
private static boolean loaded;
private static List<QuestTrackerSnapshotPayload.Entry> entries = List.of();
private SanctuaryClientQuests() {}
static void load() {
if (loaded) return;
loaded = true;
if (!Files.isRegularFile(FILE)) return;
try (Reader reader = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) {
Settings decoded = GSON.fromJson(reader, Settings.class);
if (decoded != null) settings = decoded;
} catch (IOException | RuntimeException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not read quest tracker options.",
SanctuaryMod.DISPLAY_NAME, exception);
}
}
static void update(QuestTrackerSnapshotPayload payload) {
entries = payload.entries();
}
static void clear() {
entries = List.of();
}
static List<QuestTrackerSnapshotPayload.Entry> entries() {
return entries;
}
static boolean visible() {
load();
return settings.visible;
}
static boolean toggle() {
load();
settings.visible = !settings.visible;
save();
return settings.visible;
}
private static void save() {
try {
Files.createDirectories(FILE.getParent());
try (Writer writer = Files.newBufferedWriter(FILE, StandardCharsets.UTF_8)) {
GSON.toJson(settings, writer);
}
} catch (IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not save quest tracker options.",
SanctuaryMod.DISPLAY_NAME, exception);
}
}
private static final class Settings {
boolean visible = true;
}
}
@@ -0,0 +1,20 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import java.util.List;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.block.entity.BannerPatternLayers;
/** Bounded banner appearance stored on and synchronized for the customizable white cape. */
public record CapeBannerAppearance(DyeColor baseColor, BannerPatternLayers patterns) {
public static final int MAX_PATTERNS = 16;
public static final CapeBannerAppearance WHITE = new CapeBannerAppearance(
DyeColor.WHITE, BannerPatternLayers.EMPTY);
public CapeBannerAppearance {
baseColor = baseColor == null ? DyeColor.WHITE : baseColor;
List<BannerPatternLayers.Layer> layers = patterns == null
? List.of() : patterns.layers();
if (layers.size() > MAX_PATTERNS) layers = layers.subList(0, MAX_PATTERNS);
patterns = new BannerPatternLayers(List.copyOf(layers));
}
}
@@ -8,6 +8,7 @@ import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Server authority and multiplayer distribution for equipped cape visuals. */
public final class SanctuaryCapeService {
@@ -62,7 +63,11 @@ public final class SanctuaryCapeService {
private static void send(ServerPlayer receiver, ServerPlayer owner) {
if (!ServerPlayNetworking.canSend(receiver, CapeSyncPayload.ID)) return;
String capeId = SanctuaryCapes.itemId(SanctuaryInventoryCapacity.equippedCape(owner));
ServerPlayNetworking.send(receiver, new CapeSyncPayload(owner.getUUID(), capeId));
ItemStack cape = SanctuaryInventoryCapacity.equippedCape(owner);
String capeId = SanctuaryCapes.itemId(cape);
CapeBannerAppearance banner = SanctuaryCapes.bannerAppearance(cape);
if (banner == null) banner = CapeBannerAppearance.WHITE;
ServerPlayNetworking.send(receiver, new CapeSyncPayload(owner.getUUID(), capeId,
banner.baseColor(), banner.patterns()));
}
}
@@ -5,17 +5,21 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.Registry;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BannerPatternLayers;
/** Registration and immutable visual definitions for Sanctuary's collectible capes. */
public final class SanctuaryCapes {
public static final Identifier WHITE_CAPE_ID = SanctuaryMod.id("white_cape");
public static final List<String> PATHS = List.of(
"transgender_cape", "mojang_cape", "founders_cape", "nes_cape", "pride_cape",
"pink_cape", "feesh_gang_cape", "gameboy_cape", "blue_cape", "chicken_cape",
@@ -28,7 +32,7 @@ public final class SanctuaryCapes {
"vanilla_cape", "java_edition_cape", "migration_cape", "creeper_anniversary_cape",
"java_twitch_cape", "minecon_enderman_cape", "iron_golem_minecon_cape", "minecon_2013_cape",
"crafter_twitchcon_cape", "minecon_pickaxe_cape", "minecon_2011_cape", "mojang_classic_cape",
"blue_scroll_cape"
"blue_scroll_cape", "white_cape"
);
private static final Map<Identifier, Definition> DEFINITIONS = registerDefinitions();
private static final ResourceKey<CreativeModeTab> CREATIVE_TAB_KEY = ResourceKey.create(
@@ -74,6 +78,17 @@ public final class SanctuaryCapes {
return definition == null ? null : definition.texture();
}
public static boolean isBannerCape(String itemId) {
return WHITE_CAPE_ID.toString().equals(itemId);
}
public static CapeBannerAppearance bannerAppearance(ItemStack stack) {
if (!WHITE_CAPE_ID.toString().equals(itemId(stack))) return null;
return new CapeBannerAppearance(
stack.getOrDefault(DataComponents.BASE_COLOR, DyeColor.WHITE),
stack.getOrDefault(DataComponents.BANNER_PATTERNS, BannerPatternLayers.EMPTY));
}
public static List<Definition> definitions() {
return List.copyOf(DEFINITIONS.values());
}
@@ -83,8 +98,13 @@ public final class SanctuaryCapes {
for (String path : PATHS) {
Identifier id = SanctuaryMod.id(path);
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, id);
Item.Properties properties = new Item.Properties().stacksTo(1).setId(key);
if (id.equals(WHITE_CAPE_ID)) {
properties.component(DataComponents.BASE_COLOR, DyeColor.WHITE)
.component(DataComponents.BANNER_PATTERNS, BannerPatternLayers.EMPTY);
}
Item item = Registry.register(BuiltInRegistries.ITEM, key,
new SanctuaryCapeItem(new Item.Properties().stacksTo(1).setId(key)));
new SanctuaryCapeItem(properties));
definitions.put(id, new Definition(id, item,
SanctuaryMod.id("entity/cape/" + path)));
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.BannerCapeRenderState;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearance;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
/** Stores the synchronized banner appearance on the transient avatar render state. */
@Mixin(AvatarRenderState.class)
public final class AvatarRenderStateMixin implements BannerCapeRenderState {
@Unique private CapeBannerAppearance sanctuary$bannerCape;
@Override
public CapeBannerAppearance sanctuary$bannerCape() {
return sanctuary$bannerCape;
}
@Override
public void sanctuary$setBannerCape(CapeBannerAppearance appearance) {
sanctuary$bannerCape = appearance;
}
}
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.sanctuary.mixin.client;
import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClientCapes;
import fr.koka99cab.sanctuary26.sanctuary.client.BannerCapeRenderState;
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.core.ClientAsset;
@@ -21,11 +22,15 @@ abstract class AvatarRendererMixin {
)
private void sanctuary$applyEquippedCape(Avatar avatar, AvatarRenderState state,
float partialTick, CallbackInfo ci) {
Identifier texture = SanctuaryClientCapes.texture(avatar.getUUID());
SanctuaryClientCapes.Visual visual = SanctuaryClientCapes.visual(avatar.getUUID());
((BannerCapeRenderState) (Object) state).sanctuary$setBannerCape(
visual == null ? null : visual.banner());
Identifier texture = visual == null ? null : visual.texture();
PlayerSkin skin = state.skin;
if (texture == null || skin == null) return;
state.showCape = true;
if (visual.banner() != null) return;
state.skin = PlayerSkin.insecure(skin.body(), new ClientAsset.ResourceTexture(texture),
skin.elytra(), skin.model());
state.showCape = true;
}
}
@@ -1,29 +1,44 @@
package fr.koka99cab.sanctuary26.sanctuary.network;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeBannerAppearance;
import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.block.entity.BannerPatternLayers;
/** Announces one player's currently equipped Sanctuary cape to clients. */
public record CapeSyncPayload(UUID playerId, String capeItemId) implements CustomPacketPayload {
public record CapeSyncPayload(UUID playerId, String capeItemId, DyeColor baseColor,
BannerPatternLayers bannerPatterns) implements CustomPacketPayload {
public static final Type<CapeSyncPayload> ID = new Type<>(SanctuaryMod.id("s2c/cape_sync"));
private static final StreamCodec<RegistryFriendlyByteBuf, BannerPatternLayers> BOUNDED_PATTERNS_CODEC =
BannerPatternLayers.Layer.STREAM_CODEC
.apply(ByteBufCodecs.list(CapeBannerAppearance.MAX_PATTERNS))
.map(BannerPatternLayers::new, BannerPatternLayers::layers);
public static final StreamCodec<RegistryFriendlyByteBuf, CapeSyncPayload> CODEC =
CustomPacketPayload.codec(CapeSyncPayload::write, CapeSyncPayload::new);
public CapeSyncPayload {
if (playerId == null) throw new IllegalArgumentException("Cape player UUID cannot be null");
capeItemId = capeItemId == null ? "" : capeItemId;
CapeBannerAppearance bounded = new CapeBannerAppearance(baseColor, bannerPatterns);
baseColor = bounded.baseColor();
bannerPatterns = bounded.patterns();
}
private CapeSyncPayload(RegistryFriendlyByteBuf buffer) {
this(buffer.readUUID(), buffer.readUtf(128));
this(buffer.readUUID(), buffer.readUtf(128), DyeColor.STREAM_CODEC.decode(buffer),
BOUNDED_PATTERNS_CODEC.decode(buffer));
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeUUID(playerId);
buffer.writeUtf(capeItemId, 128);
DyeColor.STREAM_CODEC.encode(buffer, baseColor);
BOUNDED_PATTERNS_CODEC.encode(buffer, bannerPatterns);
}
@Override public Type<? extends CustomPacketPayload> type() { return ID; }
@@ -0,0 +1,70 @@
package fr.koka99cab.sanctuary26.sanctuary.network;
import fr.koka99cab.sanctuary26.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;
/** Bounded snapshot of the player's tracked community requests and reserved boards. */
public record QuestTrackerSnapshotPayload(List<Entry> entries) implements CustomPacketPayload {
public static final int MAX_ENTRIES = 8;
public static final Type<QuestTrackerSnapshotPayload> ID =
new Type<>(SanctuaryMod.id("s2c/quest_tracker"));
public static final StreamCodec<RegistryFriendlyByteBuf, QuestTrackerSnapshotPayload> CODEC =
CustomPacketPayload.codec(QuestTrackerSnapshotPayload::write, QuestTrackerSnapshotPayload::new);
public QuestTrackerSnapshotPayload {
entries = List.copyOf(entries == null ? List.of()
: entries.stream().limit(MAX_ENTRIES).toList());
}
private QuestTrackerSnapshotPayload(RegistryFriendlyByteBuf buffer) {
this(readEntries(buffer));
}
private void write(RegistryFriendlyByteBuf buffer) {
buffer.writeVarInt(Math.min(MAX_ENTRIES, entries.size()));
for (int index = 0; index < Math.min(MAX_ENTRIES, entries.size()); index++) {
Entry entry = entries.get(index);
buffer.writeUtf(entry.source(), 8);
buffer.writeUtf(entry.id(), 36);
buffer.writeUtf(entry.title(), 96);
buffer.writeUtf(entry.action(), 16);
buffer.writeUtf(entry.target(), 64);
buffer.writeVarInt(entry.progress());
buffer.writeVarInt(entry.required());
buffer.writeInt(entry.accent());
}
}
private static List<Entry> readEntries(RegistryFriendlyByteBuf buffer) {
int count = Math.clamp(buffer.readVarInt(), 0, MAX_ENTRIES);
List<Entry> values = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
values.add(new Entry(buffer.readUtf(8), buffer.readUtf(36), buffer.readUtf(96),
buffer.readUtf(16), buffer.readUtf(64), Math.max(0, buffer.readVarInt()),
Math.max(1, buffer.readVarInt()), buffer.readInt()));
}
return values;
}
@Override
public Type<? extends CustomPacketPayload> type() {
return ID;
}
public record Entry(String source, String id, String title, String action, String target,
int progress, int required, int accent) {
public Entry {
source = source == null ? "" : source;
id = id == null ? "" : id;
title = title == null ? "" : title;
action = action == null ? "" : action;
target = target == null ? "" : target;
progress = Math.max(0, progress);
required = Math.max(1, required);
}
}
}
@@ -12,6 +12,7 @@ import fr.koka99cab.sanctuary26.sanctuary.celestial.CelestialSkyService;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopService;
import fr.koka99cab.sanctuary26.sanctuary.shop.BlackMarketService;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService;
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerSync;
import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapeService;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
@@ -22,7 +23,7 @@ import net.minecraft.server.level.ServerPlayer;
* Networking contract for Sanctuary. Payload IDs must use the module namespace.
*/
public final class SanctuaryNetworking {
public static final int PROTOCOL_VERSION = 22;
public static final int PROTOCOL_VERSION = 23;
private static boolean initialized;
private SanctuaryNetworking() {
@@ -42,6 +43,7 @@ public final class SanctuaryNetworking {
PayloadTypeRegistry.clientboundPlay().register(BlackMarketEditorPayload.ID, BlackMarketEditorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(QuestRequestEditorPayload.ID, QuestRequestEditorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(QuestRequestConfirmPayload.ID, QuestRequestConfirmPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(QuestTrackerSnapshotPayload.ID, QuestTrackerSnapshotPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(PurchaseProgressionPayload.ID, PurchaseProgressionPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(BuildControlPayload.ID, BuildControlPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(MiningControlPayload.ID, MiningControlPayload.CODEC);
@@ -95,6 +97,7 @@ public final class SanctuaryNetworking {
SanctuaryMealHeartsService.join(handler.player);
CelestialSkyService.join(handler.player);
SanctuaryCapeService.join(handler.player);
QuestTrackerSync.sync(handler.player);
});
SanctuaryMod.LOGGER.debug("[{}] Networking contract v{} ready; Atlas snapshot registered.",
SanctuaryMod.DISPLAY_NAME, PROTOCOL_VERSION);
@@ -17,7 +17,8 @@ import net.minecraft.world.level.saveddata.SavedDataType;
/** Server-authoritative state for physical, renewable quest signs. */
public final class QuestBoardData extends SavedData {
public static final String SAVE_KEY = "black_market_quests";
public static final int DATA_VERSION = 1;
private static final int LEGACY_DATA_VERSION = 1;
public static final int DATA_VERSION = 2;
private static final int MAX_AUDIT_ENTRIES = 4096;
private static final Codec<Objective> OBJECTIVE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -66,7 +67,7 @@ public final class QuestBoardData extends SavedData {
Codec.STRING.optionalFieldOf("detail", "").forGetter(AuditEntry::detail)
).apply(instance, AuditEntry::new));
private static final Codec<QuestBoardData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
Codec.INT.optionalFieldOf("data_version", LEGACY_DATA_VERSION).forGetter(data -> data.dataVersion),
BOARD_CODEC.listOf().optionalFieldOf("boards", List.of()).forGetter(data -> data.boards),
AUDIT_CODEC.listOf().optionalFieldOf("audit", List.of()).forGetter(data -> data.audit)
).apply(instance, QuestBoardData::new));
@@ -79,7 +80,7 @@ public final class QuestBoardData extends SavedData {
public QuestBoardData() { this(DATA_VERSION, List.of(), List.of()); }
private QuestBoardData(int dataVersion, List<Board> boards, List<AuditEntry> audit) {
this.dataVersion = DATA_VERSION;
this.dataVersion = Math.max(LEGACY_DATA_VERSION, dataVersion);
this.boards = new ArrayList<>(boards);
this.audit = new ArrayList<>(audit.stream().skip(Math.max(0, audit.size() - MAX_AUDIT_ENTRIES)).toList());
}
@@ -88,10 +89,27 @@ public final class QuestBoardData extends SavedData {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for quest-board data");
QuestBoardData data = overworld.getDataStorage().computeIfAbsent(TYPE);
if (data.dataVersion < DATA_VERSION) { data.dataVersion = DATA_VERSION; data.setDirty(); }
if (data.migrateToCurrent()) data.setDirty();
return data;
}
private boolean migrateToCurrent() {
if (dataVersion >= DATA_VERSION) return false;
if (dataVersion < 2) {
for (int index = 0; index < boards.size(); index++) {
Board board = boards.get(index);
Contract contract = board.contract().orElse(null);
if (contract == null) continue;
Objective migrated = QuestBoardGenerator.migrateUnavailableTarget(contract.objective());
if (migrated != contract.objective()) {
boards.set(index, board.withContract(Optional.of(contract.withObjective(migrated))));
}
}
}
dataVersion = DATA_VERSION;
return true;
}
public List<Board> boards() { return List.copyOf(boards); }
public Optional<Board> board(String id) { return boards.stream().filter(value -> value.id.equals(id)).findFirst(); }
public Optional<Board> boardAt(String dimension, long pos) {
@@ -46,7 +46,7 @@ final class QuestBoardGenerator {
private static final List<Template> HARD = List.of(
new Template(KILL_EXACT, "minecraft:wither_skeleton", 12, 24),
new Template(KILL_EXACT, "minecraft:blaze", 16, 28),
new Template(KILL_EXACT, "minecraft:phantom", 16, 28),
new Template(KILL_EXACT, "itsalive:ghost", 16, 28),
new Template(KILL_EXACT, "minecraft:enderman", 24, 40),
new Template(DELIVER_EXACT, "minecraft:diamond", 3, 7),
new Template(DELIVER_EXACT, "minecraft:ghast_tear", 4, 8),
@@ -78,11 +78,7 @@ final class QuestBoardGenerator {
long day = Math.floorDiv(now, 86_400_000L);
long seed = boardId.hashCode() * 0x9E3779B97F4A7C15L ^ day * 0xD1B54A32D192ED03L ^ tier.ordinal();
RandomSource random = RandomSource.create(seed);
List<Template> pool = switch (tier) {
case EMERALD -> EASY;
case RUBY -> MEDIUM;
case SAPPHIRE -> HARD;
};
List<Template> pool = templates(tier);
Template template = selectable(server, pool, random);
int count = template.minimum + random.nextInt(template.maximum - template.minimum + 1);
QuestBoardData.Objective objective = new QuestBoardData.Objective(template.kind, template.target, count, 0);
@@ -93,6 +89,28 @@ final class QuestBoardGenerator {
java.util.Optional.empty(), now);
}
private static List<Template> templates(QuestBoardTier tier) {
return switch (tier) {
case EMERALD -> EASY;
case RUBY -> MEDIUM;
case SAPPHIRE -> HARD;
};
}
static boolean offersKillTarget(QuestBoardTier tier, String target) {
return templates(tier).stream().anyMatch(template -> KILL_EXACT.equals(template.kind)
&& template.target.equals(target));
}
static QuestBoardData.Objective migrateUnavailableTarget(QuestBoardData.Objective objective) {
if (objective != null && KILL_EXACT.equals(objective.kind())
&& "minecraft:phantom".equals(objective.target())) {
return new QuestBoardData.Objective(objective.kind(), "itsalive:ghost",
objective.required(), objective.progress());
}
return objective;
}
static long nextRefreshAt(String boardId, long now) {
LocalDate tomorrow = Instant.ofEpochMilli(now).atZone(ZoneOffset.UTC).toLocalDate().plusDays(1);
long midnight = tomorrow.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli();
@@ -167,6 +167,7 @@ public final class QuestBoardService {
data.audit(new QuestBoardData.AuditEntry(now, "reserve", player.getUUID().toString(), board.id(),
contract.id(), deposit.currency + ":" + deposit.amount));
writeSign(level, sign, updated);
QuestTrackerSync.sync(player);
level.playSound(null, sign.getBlockPos(), SoundEvents.VILLAGER_YES, SoundSource.BLOCKS, 0.7F, 1.1F);
player.sendSystemMessage(Component.translatable("message.sanctuary.quest_board.reserved",
contract.objective().required(), describeTarget(contract.objective()), minutes(board.questTier().reservationMillis())));
@@ -213,6 +214,7 @@ public final class QuestBoardService {
QuestBoardData.Board updated = board.withContract(Optional.of(advanced));
data.replace(updated);
refreshSign(level.getServer(), updated);
QuestTrackerSync.sync(player);
if (advanced.objective().complete()) complete(player, level.getServer(), data, updated, advanced);
}
}
@@ -234,6 +236,7 @@ public final class QuestBoardService {
data.audit(new QuestBoardData.AuditEntry(now, "complete", player.getUUID().toString(), board.id(),
contract.id(), rewardDetail(contract.reward())));
refreshSign(server, completedBoard);
QuestTrackerSync.sync(player);
ServerLevel boardLevel = level(server, board.dimension());
BlockPos boardPos = BlockPos.of(board.pos());
if (boardLevel != null && boardLevel.isLoaded(boardPos))
@@ -252,6 +255,7 @@ public final class QuestBoardService {
data.audit(new QuestBoardData.AuditEntry(System.currentTimeMillis(), "release", player.getUUID().toString(),
board.id(), contract.id(), reason));
writeSign(level, sign, released);
QuestTrackerSync.sync(player);
message(player, "abandoned");
}
@@ -276,7 +280,9 @@ public final class QuestBoardService {
data.replace(released);
data.audit(new QuestBoardData.AuditEntry(now, "expire", claim.player(), board.id(), contract.id(),
claim.depositCurrency() + ":" + claim.depositAmount()));
refreshSign(server, released); continue;
refreshSign(server, released);
QuestTrackerSync.sync(server, claim.playerId());
continue;
}
if (!board.frozen() && now >= board.nextRefreshAt()
&& (contract == null || contract.claim().isEmpty())) {
@@ -303,6 +309,7 @@ public final class QuestBoardService {
});
data.audit(new QuestBoardData.AuditEntry(now, "remove_board", actor, board.id(),
board.contract().map(QuestBoardData.Contract::id).orElse(""), reason));
QuestTrackerSync.syncBoard(server, board);
}
static boolean setFrozen(MinecraftServer server, String id, boolean frozen, String actor) {
@@ -0,0 +1,84 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** Client-side ranking for quest-panel item and mob identifiers. */
public final class QuestIdSuggestions {
public static final int LIMIT = 5;
public record Entry(String id, String path, String displayName) {
public Entry(String id, String displayName) {
this(id, pathOf(id), displayName);
}
}
public record Match(String id, String displayName, boolean preferred, int rank) {}
private QuestIdSuggestions() {}
public static List<Match> suggest(List<Entry> catalog, String query, Collection<String> preferred,
int limit) {
String needle = normalize(query);
if (needle.isEmpty()) return List.of();
Set<String> pinned = preferred == null ? Set.of() : Set.copyOf(preferred);
List<Match> matches = new ArrayList<>();
for (Entry entry : catalog) {
Integer rank = rank(entry, needle);
if (rank == null) continue;
boolean pin = pinned.contains(entry.id());
matches.add(new Match(entry.id(), entry.displayName(), pin, pin ? rank - 10 : rank));
}
matches.sort(Comparator.comparingInt(Match::rank)
.thenComparing(Match::displayName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(Match::id));
return matches.size() <= limit ? List.copyOf(matches) : List.copyOf(matches.subList(0, limit));
}
public static String resolve(List<Entry> catalog, String query, Collection<String> preferred) {
Match match = exact(catalog, query, preferred);
return match == null ? null : match.id();
}
public static Match exact(List<Entry> catalog, String query, Collection<String> preferred) {
String raw = query == null ? "" : query.strip();
if (raw.isEmpty()) return null;
String folded = normalize(raw);
for (Match match : suggest(catalog, raw, preferred, LIMIT)) {
if (normalize(match.id()).equals(folded) || normalize(pathOf(match.id())).equals(folded)
|| normalize(match.displayName()).equals(folded)) {
return match;
}
}
return null;
}
public static String normalize(String value) {
if (value == null || value.isBlank()) return "";
String folded = Normalizer.normalize(value.strip().toLowerCase(Locale.ROOT), Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "");
return folded.startsWith("minecraft:") ? folded.substring("minecraft:".length()) : folded;
}
private static Integer rank(Entry entry, String needle) {
String id = normalize(entry.id());
String path = normalize(entry.path());
String name = normalize(entry.displayName());
if (id.equals(needle) || path.equals(needle) || name.equals(needle)) return 0;
if (path.startsWith(needle) || name.startsWith(needle)) return 1;
if (id.startsWith(needle)) return 2;
if (name.contains(needle) || path.contains(needle)) return 3;
if (id.contains(needle)) return 4;
return null;
}
private static String pathOf(String id) {
int colon = id.indexOf(':');
return colon < 0 ? id : id.substring(colon + 1);
}
}
@@ -118,6 +118,7 @@ public final class QuestRequestService {
return;
}
writeSign(level, sign, draft);
QuestTrackerSync.sync(player);
openEditor(player, draft);
}
@@ -165,6 +166,8 @@ public final class QuestRequestService {
QuestRequestData.Request released = request.withClaim(Optional.empty());
if (data.replace(released)) {
writeSign(level, sign, released);
QuestTrackerSync.syncRequest(level.getServer(), released);
QuestTrackerSync.sync(player);
message(player, "abandoned");
}
} else if (BRING.equals(request.action())) {
@@ -225,6 +228,7 @@ public final class QuestRequestService {
return;
}
writeSign(level, sign, claimed);
QuestTrackerSync.syncRequest(level.getServer(), claimed);
level.playSound(null, sign.getBlockPos(), SoundEvents.VILLAGER_YES, SoundSource.BLOCKS, 0.7F, 1.1F);
player.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.claimed",
describeAction(claimed.action()), claimed.quantity(), describeTarget(claimed)));
@@ -298,6 +302,7 @@ public final class QuestRequestService {
queueStacks(level.getServer(), request.ownerId(), plan.refund(), request.id(),
"quest_request_escrow_adjustment", player);
writeSign(level, sign, configured);
QuestTrackerSync.sync(player);
level.playSound(null, payload.pos(), SoundEvents.AMETHYST_BLOCK_CHIME,
SoundSource.BLOCKS, 0.8F, 1.0F);
message(player, "created");
@@ -336,6 +341,7 @@ public final class QuestRequestService {
complete(killer, level.getServer(), data, advanced, List.of());
} else if (data.replace(advanced)) {
refreshSign(level.getServer(), advanced);
QuestTrackerSync.syncRequest(level.getServer(), advanced);
killer.sendSystemMessage(Component.translatable("message.sanctuary.quest_request.progress",
advanced.progress(), advanced.quantity()));
}
@@ -362,6 +368,7 @@ public final class QuestRequestService {
queueStacks(server, claim.playerId(), reward, request.id(),
"quest_request_reward", actor);
refreshSign(server, completed);
QuestTrackerSync.syncRequest(server, completed);
ServerLevel signLevel = level(server, request.dimension());
BlockPos signPos = BlockPos.of(request.pos());
if (signLevel != null && signLevel.isLoaded(signPos)) signLevel.playSound(null, signPos,
@@ -519,6 +526,7 @@ public final class QuestRequestService {
if (!data.remove(request)) return;
queueStacks(server, request.ownerId(), request.rewardEscrow(), request.id(),
"quest_request_" + reason, null);
QuestTrackerSync.syncRequest(server, request);
refreshedLayouts.remove(request.id());
ServerPlayer owner = server.getPlayerList().getPlayer(request.ownerId());
if (owner != null && !request.rewardEscrow().isEmpty()) message(owner, "reward_refunded");
@@ -0,0 +1,90 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Pushes a bounded tracker snapshot of quests the player accepted. */
public final class QuestTrackerSync {
public static final String SOURCE_REQUEST = "request";
public static final String SOURCE_OWNED = "owned";
public static final String SOURCE_BOARD = "board";
private QuestTrackerSync() {}
public static void sync(ServerPlayer player) {
if (!ServerPlayNetworking.canSend(player, QuestTrackerSnapshotPayload.ID)) return;
ServerPlayNetworking.send(player, new QuestTrackerSnapshotPayload(entries(player)));
}
public static void sync(MinecraftServer server, UUID... players) {
if (server == null || players == null) return;
for (UUID id : players) {
if (id == null) continue;
ServerPlayer player = server.getPlayerList().getPlayer(id);
if (player != null) sync(player);
}
}
public static void syncRequest(MinecraftServer server, QuestRequestData.Request request) {
if (request == null) return;
sync(server, request.ownerId(),
request.claim().map(QuestRequestData.Claim::playerId).orElse(null));
}
public static void syncBoard(MinecraftServer server, QuestBoardData.Board board) {
if (board == null) return;
sync(server, board.contract().flatMap(QuestBoardData.Contract::claim)
.map(QuestBoardData.Claim::playerId).orElse(null));
}
private static List<QuestTrackerSnapshotPayload.Entry> entries(ServerPlayer player) {
List<QuestTrackerSnapshotPayload.Entry> entries = new ArrayList<>();
UUID playerId = player.getUUID();
MinecraftServer server = player.level().getServer();
for (QuestRequestData.Request request : QuestRequestData.get(server).requests()) {
if (entries.size() >= QuestTrackerSnapshotPayload.MAX_ENTRIES) break;
if (!tracksAcceptedRequest(request, playerId)) continue;
entries.add(new QuestTrackerSnapshotPayload.Entry(
SOURCE_REQUEST, clip(request.id(), 36), clip(request.objective(), 96),
clip(request.action(), 16), clip(request.target(), 64),
request.progress(), request.quantity(), request.ownerColor()));
}
for (QuestBoardData.Board board : QuestBoardData.get(server).boards()) {
if (entries.size() >= QuestTrackerSnapshotPayload.MAX_ENTRIES) break;
QuestBoardData.Contract contract = board.contract().orElse(null);
if (contract == null || contract.claim().isEmpty()
|| !contract.claim().get().playerId().equals(playerId)) continue;
QuestBoardData.Objective objective = contract.objective();
entries.add(new QuestTrackerSnapshotPayload.Entry(
SOURCE_BOARD, clip(board.id(), 36), "",
clip(objective.kind(), 16), clip(objective.target(), 64),
objective.progress(), objective.required(), tierColor(board.questTier())));
}
return entries;
}
static boolean tracksAcceptedRequest(QuestRequestData.Request request, UUID playerId) {
if (request == null || playerId == null || !request.configured() || request.completed()) {
return false;
}
return request.claim().map(claim -> claim.playerId().equals(playerId)).orElse(false);
}
private static int tierColor(QuestBoardTier tier) {
return switch (tier) {
case RUBY -> 0xFF5555;
case SAPPHIRE -> 0x55FFFF;
case EMERALD -> 0x55FF55;
};
}
private static String clip(String value, int maximumLength) {
if (value == null) return "";
return value.length() <= maximumLength ? value : value.substring(0, maximumLength);
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary:item/white_cape"
}
}
@@ -38,6 +38,7 @@
"screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra",
"screen.sanctuary.quest_request.publish": "Publish",
"screen.sanctuary.quest_request.help": "The reward is removed from your inventory when published",
"screen.sanctuary.quest_request.id_help": "Type a name, then click the item to fill its ID",
"screen.sanctuary.quest_request.confirm.title": "Confirm request",
"screen.sanctuary.quest_request.confirm.accept": "Accept quest",
"screen.sanctuary.quest_request.confirm.objective": "Objective: %s",
@@ -242,6 +243,13 @@
"key.sanctuary.companion_power": "Companion power",
"key.sanctuary.zoom": "Telephoto zoom",
"key.sanctuary.switch_hotbar_row": "Cycle hotbar row",
"key.sanctuary.quest_tracker": "Quest tracker",
"hud.sanctuary.quest_tracker.toggle_on": "Quest tracker shown",
"hud.sanctuary.quest_tracker.toggle_off": "Quest tracker hidden",
"hud.sanctuary.quest_tracker.source.request": "Accepted request",
"hud.sanctuary.quest_tracker.source.owned": "Your request",
"hud.sanctuary.quest_tracker.source.board": "Daily contract",
"hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s",
"message.sanctuary.zoom": "Zoom · FOV %s",
"message.sanctuary.companion.passive_only": "This essence only has a passive power.",
"message.sanctuary.companion.cooldown": "Power ready in %s s.",
@@ -497,5 +505,6 @@
"item.sanctuary.minecon_2011_cape": "Minecon 2011 Cape",
"item.sanctuary.mojang_classic_cape": "Mojang Classic Cape",
"item.sanctuary.blue_scroll_cape": "Blue Scroll Cape",
"item.sanctuary.white_cape": "White Cape",
"dimension.sanctuary.alpha": "Alpha / Indev Island"
}
@@ -38,6 +38,7 @@
"screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra",
"screen.sanctuary.quest_request.publish": "Publier",
"screen.sanctuary.quest_request.help": "La récompense est retirée de ton inventaire à la publication",
"screen.sanctuary.quest_request.id_help": "Tape un nom puis clique l'item pour remplir l'ID",
"screen.sanctuary.quest_request.confirm.title": "Confirmer la demande",
"screen.sanctuary.quest_request.confirm.accept": "Accepter la quête",
"screen.sanctuary.quest_request.confirm.objective": "Objectif : %s",
@@ -242,6 +243,13 @@
"key.sanctuary.companion_power": "Pouvoir du familier",
"key.sanctuary.zoom": "Zoom téléobjectif",
"key.sanctuary.switch_hotbar_row": "Changer de rangée de hotbar",
"key.sanctuary.quest_tracker": "Suivi des quêtes",
"hud.sanctuary.quest_tracker.toggle_on": "Suivi des quêtes affiché",
"hud.sanctuary.quest_tracker.toggle_off": "Suivi des quêtes masqué",
"hud.sanctuary.quest_tracker.source.request": "Demande acceptée",
"hud.sanctuary.quest_tracker.source.owned": "Votre demande",
"hud.sanctuary.quest_tracker.source.board": "Contrat du jour",
"hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s",
"message.sanctuary.zoom": "Zoom · FOV %s",
"message.sanctuary.companion.passive_only": "Cette essence ne possède qu'un pouvoir passif.",
"message.sanctuary.companion.cooldown": "Pouvoir disponible dans %s s.",
@@ -497,5 +505,6 @@
"item.sanctuary.minecon_2011_cape": "Cape Minecon 2011",
"item.sanctuary.mojang_classic_cape": "Cape Mojang classique",
"item.sanctuary.blue_scroll_cape": "Cape parchemin bleu",
"item.sanctuary.white_cape": "Cape blanche",
"dimension.sanctuary.alpha": "Île Alpha / Indev"
}
@@ -31,6 +31,7 @@
"screen.sanctuary.quest_request.reward_hint.loan": "minecraft:elytra",
"screen.sanctuary.quest_request.publish": "Опубликовать",
"screen.sanctuary.quest_request.help": "При публикации награда изымается из инвентаря",
"screen.sanctuary.quest_request.id_help": "Введите название, затем кликните предмет, чтобы подставить ID",
"screen.sanctuary.quest_request.confirm.title": "Подтвердить запрос",
"screen.sanctuary.quest_request.confirm.accept": "Принять задание",
"screen.sanctuary.quest_request.confirm.objective": "Цель: %s",
@@ -154,6 +155,13 @@
"key.sanctuary.companion_power": "Способность спутника",
"key.sanctuary.zoom": "Телефото-зум",
"key.sanctuary.switch_hotbar_row": "Сменить ряд панели быстрого доступа",
"key.sanctuary.quest_tracker": "Трекер заданий",
"hud.sanctuary.quest_tracker.toggle_on": "Трекер заданий показан",
"hud.sanctuary.quest_tracker.toggle_off": "Трекер заданий скрыт",
"hud.sanctuary.quest_tracker.source.request": "Принятый запрос",
"hud.sanctuary.quest_tracker.source.owned": "Ваш запрос",
"hud.sanctuary.quest_tracker.source.board": "Дневной контракт",
"hud.sanctuary.quest_tracker.progress": "%s · %s · %s/%s",
"message.sanctuary.zoom": "Зум · FOV %s",
"message.sanctuary.companion.passive_only": "У этой сущности есть только пассивная способность.",
"message.sanctuary.companion.cooldown": "Способность будет готова через %s с.",
@@ -453,5 +461,6 @@
"item.sanctuary.minecon_2011_cape": "Плащ Minecon 2011",
"item.sanctuary.mojang_classic_cape": "Классический плащ Mojang",
"item.sanctuary.blue_scroll_cape": "Плащ синего свитка",
"item.sanctuary.white_cape": "Белый плащ",
"dimension.sanctuary.alpha": "Остров Альфа / Indev"
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "sanctuary:item/cape/white_cape"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

@@ -0,0 +1,8 @@
{
"type": "minecraft:crafting_special_shielddecoration",
"banner": "#minecraft:banners",
"result": {
"id": "sanctuary:white_cape"
},
"target": "sanctuary:white_cape"
}
+1 -1
View File
@@ -33,7 +33,7 @@
"system_type": "gameplay-system",
"namespace": "sanctuary",
"data_version": 11,
"network_protocol": 22,
"network_protocol": 23,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
@@ -17,6 +17,7 @@
],
"client": [
"client.AbstractButtonMixin",
"client.AvatarRenderStateMixin",
"client.AvatarRendererMixin",
"client.AbstractContainerScreenAccessor",
"client.AbstractContainerScreenMixin",
@@ -0,0 +1,33 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.world.item.DyeColor;
import net.minecraft.world.level.block.entity.BannerPatternLayers;
/** Executable coverage for the network and renderer banner-layer bound. */
public final class CapeBannerAppearanceSmoke {
private CapeBannerAppearanceSmoke() {
}
public static void main(String[] args) {
List<BannerPatternLayers.Layer> source = new ArrayList<>();
for (int index = 0; index < 20; index++) {
source.add(new BannerPatternLayers.Layer(null, DyeColor.RED));
}
CapeBannerAppearance bounded = new CapeBannerAppearance(
DyeColor.BLUE, new BannerPatternLayers(source));
require(bounded.baseColor() == DyeColor.BLUE, "Banner cape base color changed");
require(bounded.patterns().layers().size() == CapeBannerAppearance.MAX_PATTERNS,
"Banner cape patterns escaped their network/render bound");
source.clear();
require(bounded.patterns().layers().size() == CapeBannerAppearance.MAX_PATTERNS,
"Banner cape retained a mutable source list");
require(new CapeBannerAppearance(null, null).equals(CapeBannerAppearance.WHITE),
"Missing banner components did not fall back to a plain white cape");
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -35,6 +35,16 @@ public final class QuestBoardModelSmoke {
"A mismatched dedicated sign accepted the wrong quest tier");
require(QuestBoardService.canActivateDedicatedSign(QuestBoardTier.SAPPHIRE, QuestBoardTier.SAPPHIRE),
"A dedicated quest sign rejected its own tier");
require(!QuestBoardGenerator.offersKillTarget(QuestBoardTier.SAPPHIRE, "minecraft:phantom"),
"Sapphire quests still offer disabled vanilla phantoms");
require(QuestBoardGenerator.offersKillTarget(QuestBoardTier.SAPPHIRE, "itsalive:ghost"),
"Sapphire quests do not offer the available ghost replacement");
QuestBoardData.Objective legacyPhantom = new QuestBoardData.Objective(
QuestBoardGenerator.KILL_EXACT, "minecraft:phantom", 16, 7);
QuestBoardData.Objective migratedGhost = QuestBoardGenerator.migrateUnavailableTarget(legacyPhantom);
require(migratedGhost.target().equals("itsalive:ghost")
&& migratedGhost.required() == 16 && migratedGhost.progress() == 7,
"Saved phantom quest migration must preserve requirement and progress");
long now = 1_800_000_000_000L;
long refresh = QuestBoardGenerator.nextRefreshAt("board", now);
require(refresh > now && refresh - now < 28L * 60L * 60L * 1_000L,
@@ -0,0 +1,40 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import java.util.List;
import java.util.Set;
/** Ranking for typed item names, accent-folded labels and inventory pins. */
public final class QuestIdSuggestionsSmoke {
private QuestIdSuggestionsSmoke() {}
public static void main(String[] args) {
List<QuestIdSuggestions.Entry> catalog = List.of(
new QuestIdSuggestions.Entry("minecraft:diamond", "Diamant"),
new QuestIdSuggestions.Entry("minecraft:diamond_sword", "Épée en diamant"),
new QuestIdSuggestions.Entry("minecraft:wheat", "Blé"),
new QuestIdSuggestions.Entry("anotherworld:ruby", "Rubis"),
new QuestIdSuggestions.Entry("minecraft:zombie_head", "Tête de zombie"));
require(QuestIdSuggestions.suggest(catalog, "diam", Set.of(), 7).getFirst().id()
.equals("minecraft:diamond"), "Prefix must prefer the shortest close name");
require(QuestIdSuggestions.suggest(catalog, "epee", Set.of(), 7).getFirst().id()
.equals("minecraft:diamond_sword"), "Accent-folded display names must match");
require(QuestIdSuggestions.suggest(catalog, "ble", Set.of(), 7).getFirst().id()
.equals("minecraft:wheat"), "Blé must resolve from ble");
require(QuestIdSuggestions.resolve(catalog, "rubis", Set.of())
.equals("anotherworld:ruby"), "Exact display names must resolve to their ID");
require(QuestIdSuggestions.resolve(catalog, "diamond", Set.of())
.equals("minecraft:diamond"), "Bare vanilla paths must resolve");
require(QuestIdSuggestions.suggest(catalog, "", Set.of("anotherworld:ruby"), 7).isEmpty(),
"An empty query must not invent suggestions");
require(QuestIdSuggestions.suggest(catalog, "zzz", Set.of(), 7).isEmpty(),
"Unknown names must produce no suggestion");
require(QuestIdSuggestions.resolve(catalog, "", Set.of()) == null,
"Blank fields must not invent an ID");
require(QuestIdSuggestions.resolve(catalog, "diam", Set.of()) == null,
"A partial name must wait for an explicit click");
}
private static void require(boolean value, String message) {
if (!value) throw new AssertionError(message);
}
}
@@ -0,0 +1,52 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Bounds and source tags for the concept quest-tracker snapshot. */
public final class QuestTrackerModelSmoke {
private QuestTrackerModelSmoke() {}
public static void main(String[] args) {
List<QuestTrackerSnapshotPayload.Entry> overflow = new ArrayList<>();
for (int index = 0; index < QuestTrackerSnapshotPayload.MAX_ENTRIES + 3; index++) {
overflow.add(new QuestTrackerSnapshotPayload.Entry(QuestTrackerSync.SOURCE_REQUEST,
"id-" + index, "Objective " + index, "kill", "minecraft:zombie", index, 10, 0xCC77AA));
}
QuestTrackerSnapshotPayload payload = new QuestTrackerSnapshotPayload(overflow);
require(payload.entries().size() == QuestTrackerSnapshotPayload.MAX_ENTRIES,
"Tracker snapshot must stay bounded");
require(payload.entries().getFirst().source().equals(QuestTrackerSync.SOURCE_REQUEST),
"Request source tag must be preserved");
QuestTrackerSnapshotPayload.Entry owned = new QuestTrackerSnapshotPayload.Entry(
QuestTrackerSync.SOURCE_OWNED, "owned", "Posted", "bring", "minecraft:wheat", 0, 4, 0xFFFFFF);
QuestTrackerSnapshotPayload.Entry board = new QuestTrackerSnapshotPayload.Entry(
QuestTrackerSync.SOURCE_BOARD, "board", "", "kill_exact", "minecraft:skeleton", 2, 8, 0x55FF55);
QuestTrackerSnapshotPayload mixed = new QuestTrackerSnapshotPayload(List.of(owned, board));
require(mixed.entries().get(0).required() == 4 && mixed.entries().get(1).progress() == 2,
"Owned and board progress must be preserved");
require(new QuestTrackerSnapshotPayload(null).entries().isEmpty(),
"Null snapshot must become an empty tracker");
UUID owner = UUID.fromString("6c43c35a-3498-41bb-a77e-10c679e635d1");
UUID claimant = UUID.fromString("8ff11ade-a604-4d77-a770-d487684367a1");
QuestRequestData.Request posted = new QuestRequestData.Request("request", "minecraft:overworld",
42L, true, owner.toString(), 0xCC77AA, "Koka", "Posted", QuestRequestService.KILL,
QuestRequestService.MOB, "minecraft:zombie", 3, QuestRequestService.REWARD_GIFT,
"minecraft:diamond", 1, true, Optional.empty(), List.of(), 0, false);
require(!QuestTrackerSync.tracksAcceptedRequest(posted, owner),
"A posted request must stay off the author's tracker");
QuestRequestData.Request claimed = posted.withClaim(Optional.of(
new QuestRequestData.Claim(claimant.toString(), "Hunter", 0x44AAFF, 10L)));
require(!QuestTrackerSync.tracksAcceptedRequest(claimed, owner),
"The author must not see a claimed request they posted");
require(QuestTrackerSync.tracksAcceptedRequest(claimed, claimant),
"Only the player who accepted the request appears on the tracker");
}
private static void require(boolean value, String message) {
if (!value) throw new AssertionError(message);
}
}