diff --git a/CHANGELOG.md b/CHANGELOG.md index a5de14a..82db418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,15 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve ## Non distribué -Modules modifiés : **It's Alive `0.0.0-alpha.36`** et **Sanctuary `0.0.0-alpha.119`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release. +Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`** et **Ambiance `0.0.0-alpha.15`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release. - remplace les pages noires du Livre interdit par l’affichage clair du livre vanilla ; - conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4. - rétablit l'accès au Black Market par clic molette sur un panneau, avec une intention vide et une nouvelle validation complète de la cible côté serveur ; - conserve les données Sanctuary v11 et porte son protocole réseau de 21 à 22, sans nouvel ID persistant. +- ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de l’insérer dans un jukebox ; +- valide strictement l’hôte et l’identifiant vidéo côté serveur, ne transmet qu’un identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ; +- diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6. ## `26.2.0-alpha.210` diff --git a/README.md b/README.md index b905fa3..ac3def3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`) |---|---|---|---| | `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs | | `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia | -| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, calendrier et composition visuelle accessible | +| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible | | `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur | | `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux | | `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo | diff --git a/ambiance/WHITE_DISC.md b/ambiance/WHITE_DISC.md new file mode 100644 index 0000000..95c4223 --- /dev/null +++ b/ambiance/WHITE_DISC.md @@ -0,0 +1,31 @@ +# Disque blanc YouTube + +Le disque blanc appartient à **Ambiance**, propriétaire de l’audio positionnel. Il conserve l’ID +`ambiance:white_disc` et se fabrique avec un disque 13 entouré de huit colorants blancs. Pour le +lire, il faut le renommer dans une enclume avec une URL YouTube HTTPS puis l’insérer dans un +jukebox vanilla. + +## Contrat réseau et sécurité + +- le serveur reste l’autorité de l’insertion et refuse les noms qui ne correspondent pas à une URL + HTTPS exacte de `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com` ou + `youtu.be` ; +- les formats `watch`, `shorts`, `embed`, `live` et le lien court sont acceptés, mais l’identifiant + doit contenir exactement onze caractères ASCII autorisés ; +- le payload S2C ne contient que la position du jukebox et cet identifiant borné, jamais une URL + 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 s’arrê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. + +## Dépendances clientes embarquées + +Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et youtube-source `1.18.2` (MIT), ainsi que +leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans +les archives d’origine. L’accès YouTube est effectué uniquement par le client et peut cesser de +fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire n’est +acceptée par le serveur. diff --git a/ambiance/build.gradle b/ambiance/build.gradle index fcd2f96..c0b731d 100644 --- a/ambiance/build.gradle +++ b/ambiance/build.gradle @@ -1,8 +1,43 @@ import groovy.json.JsonSlurper +configurations { + whiteDiscLibraries + implementation.extendsFrom whiteDiscLibraries + include.extendsFrom whiteDiscLibraries +} + dependencies { implementation project(':sanctuary') compileOnly "maven.modrinth:YL57xq9U:oaD6KQls" + whiteDiscLibraries("dev.arbjerg:lavaplayer:2.2.7") { + exclude group: "org.slf4j", module: "slf4j-api" + exclude group: "org.jetbrains", module: "annotations" + } + whiteDiscLibraries("dev.lavalink.youtube:v2:1.18.2") { + exclude group: "org.slf4j", module: "slf4j-api" + exclude group: "org.jetbrains", module: "annotations" + } + [ + "dev.arbjerg:lavaplayer-natives:2.2.7", + "dev.arbjerg:lava-common:2.2.7", + "dev.lavalink.youtube:common:1.18.2", + "org.mozilla:rhino-engine:1.7.15", + "org.mozilla:rhino:1.7.15", + "commons-io:commons-io:2.13.0", + "org.jsoup:jsoup:1.16.1", + "net.iharder:base64:2.3.9", + "org.json:json:20240303", + "org.apache.httpcomponents:httpclient:4.5.14", + "org.apache.httpcomponents:httpcore:4.4.16", + "commons-logging:commons-logging:1.2", + "commons-codec:commons-codec:1.11", + "com.fasterxml.jackson.core:jackson-core:2.15.2", + "com.fasterxml.jackson.core:jackson-databind:2.15.2", + "com.fasterxml.jackson.core:jackson-annotations:2.15.2", + "com.grack:nanojson:1.7" + ].each { coordinates -> + whiteDiscLibraries(coordinates) { transitive = false } + } } tasks.register("ambianceVisualApiSmoke", JavaExec) { @@ -21,13 +56,21 @@ tasks.register("ambianceScheduleSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.ambiance.environment.AmbianceScheduleSmoke" } +tasks.register("whiteDiscUrlSmoke", JavaExec) { + group = "verification" + description = "Checks strict and bounded YouTube URL parsing for the white disc." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke" +} + tasks.named("test") { failOnNoDiscoveredTests = false } tasks.register("verifyAmbiance") { group = "verification" - description = "Checks Ambiance alpha.12, including timed weather overrides, playable night fog and weather/lunar controls." + description = "Checks Ambiance alpha.15, including the bounded YouTube white-disc playback contract." inputs.files(fileTree("src/main/java")) inputs.files(fileTree("src/main/resources")) inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json")) @@ -68,8 +111,17 @@ tasks.register("verifyAmbiance") { def languages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language -> [(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))] } + def ambianceLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language -> + [(language): new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${language}.json"))] + } + def whiteDiscTexture = javax.imageio.ImageIO.read( + file("src/main/resources/assets/ambiance/textures/item/white_disc.png")) + def whiteDiscRecipe = new JsonSlurper().parse( + file("src/main/resources/data/ambiance/recipe/white_disc.json")) + def whiteDiscSong = new JsonSlurper().parse( + file("src/main/resources/data/ambiance/jukebox_song/white_disc.json")) - if (project.version.toString() != "0.0.0-alpha.14" + if (project.version.toString() != "0.0.0-alpha.15" || 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" @@ -84,7 +136,7 @@ tasks.register("verifyAmbiance") { || migration.compatibility?.ambiance_data_version != 1 || migration.compatibility?.ambiance_network_protocol != 2 || manifest.custom?.sanctuary26?.data_version != 2 - || manifest.custom?.sanctuary26?.network_protocol != 5 + || manifest.custom?.sanctuary26?.network_protocol != 6 || dailyMigration.target?.pack_version != "26.2.0-alpha.128" || dailyMigration.target?.modules?.ambiance != "0.0.0-alpha.2" || dailyMigration.lunar_schedule?.cycle != "minecraft_native_eight_days" @@ -262,6 +314,36 @@ tasks.register("verifyAmbiance") { || !javaText.contains("Mth.lerp(night, 1.0F, 0.98F)")) { throw new GradleException("Ambiance alpha.11 playable-night fog contract is incomplete") } + if (!javaText.contains('id("white_disc_playback")') + || !javaText.contains('readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)') + || !javaText.contains('PlayerLookup.tracking(level, pos)') + || !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)') + || !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4') + || !javaText.contains('REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS') + || !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('SoundSource.RECORDS') + || !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)') + || !javaText.contains('LONG_HOSTS = Set.of(') + || !javaText.contains('"https".equalsIgnoreCase(uri.getScheme())') + || !file("src/main/resources/assets/ambiance/items/white_disc.json").isFile() + || !file("src/main/resources/assets/ambiance/models/item/white_disc.json").isFile() + || whiteDiscTexture == null || whiteDiscTexture.width != 16 || whiteDiscTexture.height != 16 + || whiteDiscRecipe.result?.id != "ambiance:white_disc" + || whiteDiscRecipe.key?.W != "minecraft:white_dye" + || whiteDiscSong.sound_event != "minecraft:intentionally_empty" + || whiteDiscSong.length_in_seconds != 1800 + || ambianceLanguages.any { locale, translations -> + !["item.ambiance.white_disc", "tooltip.ambiance.white_disc", + "message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every { + translations.containsKey(it) + } + } + || !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7') + || !file("build.gradle").text.contains('dev.lavalink.youtube:v2:1.18.2')) { + throw new GradleException("Ambiance alpha.15 white-disc playback contract is incomplete") + } [ 'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology', @@ -313,4 +395,5 @@ tasks.named("check") { dependsOn tasks.named("verifyAmbiance") dependsOn tasks.named("ambianceScheduleSmoke") dependsOn tasks.named("ambianceVisualApiSmoke") + dependsOn tasks.named("whiteDiscUrlSmoke") } diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java index f5cfc57..d376349 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java @@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceBloodNightAssault; import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceWeatherCommands; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceNetworking; import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscService; import net.fabricmc.api.ModInitializer; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -29,6 +30,7 @@ public final class AmbianceMod implements ModInitializer { public void onInitialize() { AmbianceRegistries.initialize(); AmbianceNetworking.initialize(); + WhiteDiscService.initialize(); RealtimeMod.initialize(); AmbianceEnvironmentController.initialize(); AmbianceFirstSpawnVision.initialize(); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java index 1625c20..46ac7d2 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java @@ -4,7 +4,10 @@ import fr.koka99cab.realtime.client.RealtimeClient; import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceEnvironmentPayload; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads; +import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload; +import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; @@ -41,11 +44,15 @@ public final class AmbianceClient implements ClientModInitializer { context.client().execute(() -> AmbianceClientVisualEffects.apply(payload))); ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Clear.ID, (payload, context) -> context.client().execute(AmbianceClientVisualEffects::clear)); + ClientPlayNetworking.registerGlobalReceiver(WhiteDiscPlaybackPayload.ID, (payload, context) -> + context.client().execute(() -> WhiteDiscAudioPlayer.play( + context.client(), payload.pos(), payload.videoId()))); visualSettingsKey = KeyMappingHelper.registerKeyMapping(new KeyMapping( "key.ambiance.visual_settings", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, AMBIANCE_CATEGORY)); ClientTickEvents.END_CLIENT_TICK.register(client -> { AmbianceClientVisualEffects.tick(); + WhiteDiscAudioPlayer.tick(client); while (visualSettingsKey.consumeClick()) { if (client.gui.screen() == null) client.gui.setScreen(new AmbianceVisualSettingsScreen(null)); } @@ -54,7 +61,9 @@ public final class AmbianceClient implements ClientModInitializer { AmbianceClientEnvironment.reset(); DynamicTorchLights.reset(); AmbianceClientVisualEffects.clear(); + WhiteDiscAudioPlayer.clear(); }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown()); AmbianceVisualOverlay.register(); AmbianceBiomeTints.registerColorResolvers(); registerWaterFluidTint(); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java new file mode 100644 index 0000000..0e959f8 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java @@ -0,0 +1,87 @@ +package fr.koka99cab.sanctuary26.ambiance.client.audio; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; +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. */ +final class LavaplayerAudioStream implements AudioStream { + static final int SAMPLE_RATE = 48_000; + private static final int FRAME_SAMPLES = 960; + private static final int BYTES_PER_SAMPLE = 2; + private static final AudioFormat FORMAT = + new AudioFormat(SAMPLE_RATE, 16, 1, true, false); + private final AudioPlayer player; + private byte[] pending = new byte[0]; + private int pendingOffset; + private volatile boolean closed; + private volatile boolean ended; + + LavaplayerAudioStream(AudioPlayer player) { + this.player = player; + } + + @Override + public AudioFormat getFormat() { + return FORMAT; + } + + @Override + public ByteBuffer read(int maximumBytes) throws IOException { + if (closed || maximumBytes <= 0) return null; + ByteBuffer output = ByteBuffer.allocateDirect(maximumBytes); + while (output.hasRemaining() && !closed) { + if (pendingOffset < pending.length) { + int length = Math.min(output.remaining(), pending.length - pendingOffset); + output.put(pending, pendingOffset, length); + pendingOffset += length; + continue; + } + AudioFrame frame = nextFrame(); + if (frame == null) { + if (ended) break; + int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE); + for (int index = 0; index < silence; index++) output.put((byte) 0); + continue; + } + if (frame.isTerminator()) { + ended = true; + break; + } + pending = frame.getData(); + pendingOffset = 0; + } + if (output.position() == 0 && ended) return null; + output.flip(); + return output; + } + + private AudioFrame nextFrame() throws IOException { + try { + AudioFrame frame = player.provide(2, TimeUnit.SECONDS); + if (frame == null && player.getPlayingTrack() == null) ended = true; + return frame; + } catch (TimeoutException ignored) { + return null; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while buffering the white disc", interrupted); + } + } + + boolean ended() { + return ended; + } + + @Override + public void close() { + if (closed) return; + closed = true; + player.destroy(); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java new file mode 100644 index 0000000..0001011 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java @@ -0,0 +1,213 @@ +package fr.koka99cab.sanctuary26.ambiance.client.audio; + +import com.mojang.blaze3d.audio.Library; +import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.lavalink.youtube.YoutubeAudioSourceManager; +import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor; +import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.ChannelAccess; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.JukeboxBlock; +import net.minecraft.world.phys.Vec3; + +/** Client-only, positional and resource-bounded YouTube playback for white discs. */ +public final class WhiteDiscAudioPlayer { + private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4; + 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; + private static final DefaultAudioPlayerManager MANAGER = createManager(); + private static final Map REQUESTS = new HashMap<>(); + private static final LinkedHashMap PLAYBACKS = new LinkedHashMap<>(); + private static long requestSequence; + private static boolean shutdown; + + private WhiteDiscAudioPlayer() { + } + + public static void play(Minecraft client, BlockPos pos, String videoId) { + if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validTarget(client, pos)) return; + long key = pos.asLong(); + if (REQUESTS.containsKey(key)) return; + boolean replacingPlayback = PLAYBACKS.containsKey(key); + if (!replacingPlayback + && REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) return; + stop(key); + long request = ++requestSequence; + REQUESTS.put(key, request); + client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading")); + MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() { + @Override + public void trackLoaded(AudioTrack track) { + client.execute(() -> acceptTrack(client, pos, request, track)); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) { + AudioTrack selected = playlist.getSelectedTrack(); + if (selected == null && !playlist.getTracks().isEmpty()) selected = playlist.getTracks().getFirst(); + if (selected == null) noMatches(); + else trackLoaded(selected); + } + + @Override + public void noMatches() { + client.execute(() -> fail(client, key, request)); + } + + @Override + public void loadFailed(FriendlyException exception) { + AmbianceMod.LOGGER.warn("Unable to load white-disc YouTube video {}: {}", videoId, + exception.getMessage()); + client.execute(() -> fail(client, key, request)); + } + }); + } + + private static void acceptTrack(Minecraft client, BlockPos pos, long request, AudioTrack track) { + long key = pos.asLong(); + if (REQUESTS.getOrDefault(key, -1L) != request) return; + if (!validTarget(client, pos)) { + REQUESTS.remove(key); + return; + } + if (track.getInfo().isStream || track.getDuration() <= 0L + || track.getDuration() > MAXIMUM_TRACK_MILLIS) { + REQUESTS.remove(key); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.too_long")); + return; + } + while (PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) { + stop(PLAYBACKS.keySet().iterator().next()); + } + ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager()) + .ambiance$getSoundEngine()).ambiance$getChannelAccess(); + channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> { + if (handle == null || REQUESTS.getOrDefault(key, -1L) != request) { + if (handle != null) handle.release(); + return; + } + AudioPlayer player = MANAGER.createPlayer(); + player.playTrack(track); + LavaplayerAudioStream stream = new LavaplayerAudioStream(player); + Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream); + PLAYBACKS.put(key, playback); + REQUESTS.remove(key); + float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); + handle.execute(channel -> { + channel.setSelfPosition(Vec3.atCenterOf(pos)); + channel.setRelative(false); + channel.linearAttenuation(ATTENUATION_DISTANCE); + channel.setVolume(volume); + channel.attachBufferStream(stream); + channel.play(); + }); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title)); + })); + } + + private static void fail(Minecraft client, long key, long request) { + if (REQUESTS.getOrDefault(key, -1L) != request) return; + REQUESTS.remove(key); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.failed")); + } + + public static void stop(BlockPos pos) { + stop(pos.asLong()); + } + + private static void stop(long key) { + REQUESTS.remove(key); + Playback playback = PLAYBACKS.remove(key); + if (playback == null) return; + playback.stream().close(); + playback.handle().execute(channel -> channel.stop()); + playback.handle().release(); + } + + public static void tick(Minecraft client) { + if (client.level == null) { + clear(); + return; + } + long now = System.nanoTime(); + for (Map.Entry entry : Map.copyOf(PLAYBACKS).entrySet()) { + Playback playback = entry.getValue(); + boolean missing = !client.level.dimension().equals(playback.dimension()) + || !client.level.hasChunkAt(playback.pos()) + || !client.level.getBlockState(playback.pos()).is(Blocks.JUKEBOX) + || !client.level.getBlockState(playback.pos()).getValue(JukeboxBlock.HAS_RECORD); + if (missing || playback.finishedFor(now) >= FINISHED_GRACE_NANOS) { + stop(entry.getKey()); + continue; + } + float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); + playback.handle().execute(channel -> channel.setVolume(volume)); + } + } + + public static void clear() { + REQUESTS.clear(); + for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key); + } + + public static void shutdown() { + if (shutdown) return; + shutdown = true; + clear(); + MANAGER.shutdown(); + } + + private static DefaultAudioPlayerManager createManager() { + DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager(); + manager.getConfiguration().setOutputFormat( + new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false)); + manager.setFrameBufferDuration(5_000); + manager.setItemLoaderThreadPoolSize(2); + manager.registerSourceManager(new YoutubeAudioSourceManager(false)); + return manager; + } + + private static boolean validTarget(Minecraft client, BlockPos pos) { + return client.player != null && client.level != null + && client.player.distanceToSqr(Vec3.atCenterOf(pos)) + <= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D + && client.level.getBlockState(pos).is(Blocks.JUKEBOX) + && client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD); + } + + private record Playback(BlockPos pos, ResourceKey dimension, + ChannelAccess.ChannelHandle handle, + LavaplayerAudioStream stream, long[] finishedAt) { + private Playback(BlockPos pos, ResourceKey dimension, + ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) { + this(pos, dimension, handle, stream, new long[1]); + } + + private long finishedFor(long now) { + if (!stream.ended()) return 0L; + if (finishedAt[0] == 0L) finishedAt[0] = now; + return now - finishedAt[0]; + } + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java new file mode 100644 index 0000000..8869266 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java @@ -0,0 +1,23 @@ +package fr.koka99cab.sanctuary26.ambiance.item; + +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; + +/** Blank disc whose custom name may hold a validated YouTube URL. */ +public final class WhiteDiscItem extends Item { + public WhiteDiscItem(Properties properties) { + super(properties); + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, + Consumer tooltip, TooltipFlag flag) { + tooltip.accept(Component.translatable("tooltip.ambiance.white_disc") + .withStyle(ChatFormatting.GRAY)); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java new file mode 100644 index 0000000..aa5465e --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java @@ -0,0 +1,19 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +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; + +@Mixin(LevelRenderer.class) +public abstract class LevelRendererMixin { + @Inject(method = "levelEvent", at = @At("HEAD")) + private void ambiance$stopWhiteDisc(Entity source, int type, BlockPos pos, int data, + CallbackInfo callback) { + if (type == 1011) WhiteDiscAudioPlayer.stop(pos); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java new file mode 100644 index 0000000..7728f7a --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java @@ -0,0 +1,12 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import net.minecraft.client.sounds.ChannelAccess; +import net.minecraft.client.sounds.SoundEngine; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SoundEngine.class) +public interface SoundEngineAccessor { + @Accessor("channelAccess") + ChannelAccess ambiance$getChannelAccess(); +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java new file mode 100644 index 0000000..ef9ed00 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java @@ -0,0 +1,12 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.client.sounds.SoundManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SoundManager.class) +public interface SoundManagerAccessor { + @Accessor("soundEngine") + SoundEngine ambiance$getSoundEngine(); +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java index 3ff21c4..a46c3d5 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java @@ -13,7 +13,7 @@ import net.minecraft.server.level.ServerPlayer; * Networking contract for Ambiance. Payload IDs must use the module namespace. */ public final class AmbianceNetworking { - public static final int PROTOCOL_VERSION = 5; + public static final int PROTOCOL_VERSION = 6; private static boolean initialized; private AmbianceNetworking() { @@ -34,6 +34,8 @@ public final class AmbianceNetworking { AmbianceCanapliaVisualPayloads.BadTrip.ID, AmbianceCanapliaVisualPayloads.BadTrip.CODEC); PayloadTypeRegistry.clientboundPlay().register( AmbianceCanapliaVisualPayloads.Clear.ID, AmbianceCanapliaVisualPayloads.Clear.CODEC); + PayloadTypeRegistry.clientboundPlay().register( + WhiteDiscPlaybackPayload.ID, WhiteDiscPlaybackPayload.CODEC); ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> send(handler.player, AmbianceEnvironmentController.snapshot(server)) ); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java new file mode 100644 index 0000000..a6bb4f3 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java @@ -0,0 +1,34 @@ +package fr.koka99cab.sanctuary26.ambiance.network; + +import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** Bounded clientbound intent: a server-resolved jukebox position and an 11-character video ID. */ +public record WhiteDiscPlaybackPayload(BlockPos pos, String videoId) implements CustomPacketPayload { + public static final Type ID = + new Type<>(AmbianceMod.id("white_disc_playback")); + public static final StreamCodec CODEC = + CustomPacketPayload.codec(WhiteDiscPlaybackPayload::write, WhiteDiscPlaybackPayload::new); + + public WhiteDiscPlaybackPayload { + if (!WhiteDiscUrl.validVideoId(videoId)) videoId = ""; + } + + private WhiteDiscPlaybackPayload(RegistryFriendlyByteBuf buffer) { + this(buffer.readBlockPos(), buffer.readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)); + } + + private void write(RegistryFriendlyByteBuf buffer) { + buffer.writeBlockPos(pos); + buffer.writeUtf(videoId, WhiteDiscUrl.VIDEO_ID_LENGTH); + } + + @Override + public Type type() { + return ID; + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java index 75e57ec..729e74c 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java @@ -1,12 +1,32 @@ package fr.koka99cab.sanctuary26.ambiance.registry; import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.item.WhiteDiscItem; +import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.JukeboxSong; +import net.minecraft.world.item.Rarity; /** * Single registration entry point for the Ambiance namespace. * Feature registries must be called from here to keep initialization ordering explicit. */ public final class AmbianceRegistries { + public static final ResourceKey WHITE_DISC_SONG = ResourceKey.create( + Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc")); + private static final ResourceKey WHITE_DISC_KEY = ResourceKey.create( + Registries.ITEM, AmbianceMod.id("white_disc")); + private static final ResourceKey TOOLS_TAB = ResourceKey.create( + Registries.CREATIVE_MODE_TAB, Identifier.withDefaultNamespace("tools_and_utilities")); + public static final Item WHITE_DISC = Registry.register(BuiltInRegistries.ITEM, WHITE_DISC_KEY, + new WhiteDiscItem(new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON) + .jukeboxPlayable(WHITE_DISC_SONG).setId(WHITE_DISC_KEY))); private static boolean initialized; private AmbianceRegistries() { @@ -17,7 +37,8 @@ public final class AmbianceRegistries { return; } initialized = true; - AmbianceMod.LOGGER.debug("[{}] Empty registry hub initialized.", AmbianceMod.DISPLAY_NAME); + CreativeModeTabEvents.modifyOutputEvent(TOOLS_TAB).register(entries -> entries.accept(WHITE_DISC)); + AmbianceMod.LOGGER.debug("[{}] Registry hub initialized with the white disc.", + AmbianceMod.DISPLAY_NAME); } } - diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java new file mode 100644 index 0000000..f79adc0 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java @@ -0,0 +1,70 @@ +package fr.koka99cab.sanctuary26.ambiance.white_disc; + +import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload; +import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.JukeboxPlayable; +import net.minecraft.world.level.block.JukeboxBlock; +import net.minecraft.world.level.block.entity.JukeboxBlockEntity; + +/** Server-authoritative insertion and bounded playback dispatch for the white disc. */ +public final class WhiteDiscService { + private static boolean initialized; + + private WhiteDiscService() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + UseBlockCallback.EVENT.register((player, level, hand, hit) -> { + BlockPos pos = hit.getBlockPos(); + if (!(level.getBlockEntity(pos) instanceof JukeboxBlockEntity jukebox) + || jukebox.getBlockState().getValue(JukeboxBlock.HAS_RECORD)) return InteractionResult.PASS; + ItemStack held = player.getItemInHand(hand); + if (!held.is(AmbianceRegistries.WHITE_DISC)) return InteractionResult.PASS; + + Optional videoId = videoId(held); + if (videoId.isEmpty()) { + if (!level.isClientSide()) player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.invalid_url")); + return InteractionResult.FAIL; + } + if (level.isClientSide()) return InteractionResult.SUCCESS; + InteractionResult result = JukeboxPlayable.tryInsertIntoJukebox(level, pos, held, player); + if (result.consumesAction() && level instanceof ServerLevel serverLevel) { + broadcast(serverLevel, pos, videoId.get(), player instanceof ServerPlayer serverPlayer + ? serverPlayer : null); + } + return result; + }); + } + + public static Optional videoId(ItemStack stack) { + Component name = stack.get(DataComponents.CUSTOM_NAME); + return name == null ? Optional.empty() : WhiteDiscUrl.videoId(name.getString()); + } + + private static void broadcast(ServerLevel level, BlockPos pos, String videoId, ServerPlayer inserter) { + WhiteDiscPlaybackPayload payload = new WhiteDiscPlaybackPayload(pos, videoId); + Set receivers = new LinkedHashSet<>(PlayerLookup.tracking(level, pos)); + if (inserter != null) receivers.add(inserter); + for (ServerPlayer receiver : receivers) { + if (ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)) { + ServerPlayNetworking.send(receiver, payload); + } + } + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java new file mode 100644 index 0000000..08436df --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java @@ -0,0 +1,74 @@ +package fr.koka99cab.sanctuary26.ambiance.white_disc; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** Strict, side-effect-free YouTube URL parser used before any client network request. */ +public final class WhiteDiscUrl { + public static final int MAXIMUM_URL_LENGTH = 256; + public static final int VIDEO_ID_LENGTH = 11; + private static final Pattern VIDEO_ID = Pattern.compile("[A-Za-z0-9_-]{11}"); + private static final Set LONG_HOSTS = Set.of( + "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"); + + private WhiteDiscUrl() { + } + + public static Optional videoId(String value) { + if (value == null || value.isBlank() || value.length() > MAXIMUM_URL_LENGTH) return Optional.empty(); + try { + URI uri = new URI(value.strip()); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null + || (uri.getPort() != -1 && uri.getPort() != 443)) return Optional.empty(); + String host = uri.getHost(); + if (host == null) return Optional.empty(); + host = host.toLowerCase(Locale.ROOT); + if ("youtu.be".equals(host)) return valid(singlePathSegment(uri.getRawPath())); + if (!LONG_HOSTS.contains(host)) return Optional.empty(); + + String path = uri.getRawPath(); + if ("/watch".equals(path)) return valid(queryParameter(uri.getRawQuery(), "v")); + for (String prefix : new String[] {"/shorts/", "/embed/", "/live/"}) { + if (path != null && path.startsWith(prefix)) return valid(singlePathSegment(path.substring(prefix.length()))); + } + } catch (URISyntaxException ignored) { + // Invalid user text is rejected without network access. + } + return Optional.empty(); + } + + public static boolean validVideoId(String value) { + return value != null && VIDEO_ID.matcher(value).matches(); + } + + public static String canonicalUrl(String videoId) { + if (!validVideoId(videoId)) throw new IllegalArgumentException("Invalid YouTube video ID"); + return "https://www.youtube.com/watch?v=" + videoId; + } + + private static String singlePathSegment(String path) { + if (path == null || path.isEmpty()) return ""; + String segment = path.startsWith("/") ? path.substring(1) : path; + return segment.contains("/") ? "" : segment; + } + + private static String queryParameter(String query, String name) { + if (query == null) return ""; + String found = null; + for (String parameter : query.split("&", -1)) { + int equals = parameter.indexOf('='); + if (equals <= 0 || !name.equals(parameter.substring(0, equals))) continue; + if (found != null) return ""; + found = parameter.substring(equals + 1); + } + return found == null ? "" : found; + } + + private static Optional valid(String candidate) { + return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty(); + } +} diff --git a/ambiance/src/main/resources/ambiance.client.mixins.json b/ambiance/src/main/resources/ambiance.client.mixins.json index 8cf3b09..86b18c4 100644 --- a/ambiance/src/main/resources/ambiance.client.mixins.json +++ b/ambiance/src/main/resources/ambiance.client.mixins.json @@ -11,6 +11,9 @@ "GameRendererMixin", "LightCoordsUtilMixin", "LightmapRenderStateExtractorMixin", + "LevelRendererMixin", + "SoundEngineAccessor", + "SoundManagerAccessor", "SkyRendererMixin" ], "injectors": { diff --git a/ambiance/src/main/resources/assets/ambiance/items/white_disc.json b/ambiance/src/main/resources/assets/ambiance/items/white_disc.json new file mode 100644 index 0000000..e2f8f86 --- /dev/null +++ b/ambiance/src/main/resources/assets/ambiance/items/white_disc.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "ambiance:item/white_disc" + } +} diff --git a/ambiance/src/main/resources/assets/ambiance/lang/en_us.json b/ambiance/src/main/resources/assets/ambiance/lang/en_us.json index 0669c8f..b6a8ae4 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/en_us.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/en_us.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Camera wobble", "options.ambiance.visual.ghosting": "Edge ghosting", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Visual accessibility settings" + "key.ambiance.visual_settings": "Visual accessibility settings", + "item.ambiance.white_disc": "White Disc", + "jukebox_song.ambiance.white_disc": "White Disc", + "tooltip.ambiance.white_disc": "Rename it to an HTTPS YouTube URL, then insert it into a jukebox.", + "message.ambiance.white_disc.invalid_url": "Rename the white disc to a valid HTTPS YouTube URL.", + "message.ambiance.white_disc.loading": "Loading YouTube video…", + "message.ambiance.white_disc.playing": "Now playing: %s", + "message.ambiance.white_disc.failed": "This YouTube video could not be played.", + "message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes." } diff --git a/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json b/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json index fefd521..e954668 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Oscillation de la caméra", "options.ambiance.visual.ghosting": "Rémanence sur les bords", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Réglages d’accessibilité visuelle" + "key.ambiance.visual_settings": "Réglages d’accessibilité visuelle", + "item.ambiance.white_disc": "Disque blanc", + "jukebox_song.ambiance.white_disc": "Disque blanc", + "tooltip.ambiance.white_disc": "Renommez-le avec une URL YouTube HTTPS, puis insérez-le dans un jukebox.", + "message.ambiance.white_disc.invalid_url": "Renommez le disque blanc avec une URL YouTube HTTPS valide.", + "message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…", + "message.ambiance.white_disc.playing": "Lecture : %s", + "message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.", + "message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes." } diff --git a/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json b/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json index 0ef5232..3a84ce4 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Покачивание камеры", "options.ambiance.visual.ghosting": "Шлейф по краям", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Настройки визуальной доступности" + "key.ambiance.visual_settings": "Настройки визуальной доступности", + "item.ambiance.white_disc": "Белая пластинка", + "jukebox_song.ambiance.white_disc": "Белая пластинка", + "tooltip.ambiance.white_disc": "Переименуйте её в HTTPS-ссылку YouTube и вставьте в проигрыватель.", + "message.ambiance.white_disc.invalid_url": "Переименуйте белую пластинку в допустимую HTTPS-ссылку YouTube.", + "message.ambiance.white_disc.loading": "Загрузка видео YouTube…", + "message.ambiance.white_disc.playing": "Сейчас играет: %s", + "message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.", + "message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут." } diff --git a/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json b/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json new file mode 100644 index 0000000..960a335 --- /dev/null +++ b/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/template_music_disc", + "textures": { + "layer0": "ambiance:item/white_disc" + } +} diff --git a/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png b/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png new file mode 100644 index 0000000..0914f1f Binary files /dev/null and b/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png differ diff --git a/ambiance/src/main/resources/data/ambiance/jukebox_song/white_disc.json b/ambiance/src/main/resources/data/ambiance/jukebox_song/white_disc.json new file mode 100644 index 0000000..b5821d0 --- /dev/null +++ b/ambiance/src/main/resources/data/ambiance/jukebox_song/white_disc.json @@ -0,0 +1,8 @@ +{ + "comparator_output": 15, + "description": { + "translate": "jukebox_song.ambiance.white_disc" + }, + "length_in_seconds": 1800.0, + "sound_event": "minecraft:intentionally_empty" +} diff --git a/ambiance/src/main/resources/data/ambiance/recipe/white_disc.json b/ambiance/src/main/resources/data/ambiance/recipe/white_disc.json new file mode 100644 index 0000000..53ba45b --- /dev/null +++ b/ambiance/src/main/resources/data/ambiance/recipe/white_disc.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "WWW", + "WDW", + "WWW" + ], + "key": { + "D": "minecraft:music_disc_13", + "W": "minecraft:white_dye" + }, + "result": { + "id": "ambiance:white_disc" + } +} diff --git a/ambiance/src/main/resources/fabric.mod.json b/ambiance/src/main/resources/fabric.mod.json index 578602f..1cd19a0 100644 --- a/ambiance/src/main/resources/fabric.mod.json +++ b/ambiance/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "ambiance", "version": "${version}", "name": "Ambiance", - "description": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.", + "description": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.", "authors": [ "KOKA99CAB" ], @@ -36,13 +36,13 @@ "system_type": "gameplay-system", "namespace": "ambiance", "data_version": 2, - "network_protocol": 5, + "network_protocol": 6, "lifecycle": "${lifecycle}", "pack_version": "${pack_version}", "descriptions": { - "fr_fr": "Gère l'atmosphère, les lumières dynamiques, le calendrier et les effets visuels accessibles et composables, dont Canaplia.", - "en_us": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.", - "ru_ru": "Управляет атмосферой, динамическим освещением, календарём и доступными составными визуальными эффектами, включая Canaplia." + "fr_fr": "Gère l'atmosphère, les lumières dynamiques, l'audio positionnel, le calendrier et les effets visuels accessibles et composables, dont Canaplia.", + "en_us": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.", + "ru_ru": "Управляет атмосферой, динамическим освещением, позиционным звуком, календарём и доступными составными визуальными эффектами, включая Canaplia." } } } diff --git a/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrlSmoke.java b/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrlSmoke.java new file mode 100644 index 0000000..821d55b --- /dev/null +++ b/ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrlSmoke.java @@ -0,0 +1,40 @@ +package fr.koka99cab.sanctuary26.ambiance.white_disc; + +/** Executable URL-policy regression test, intentionally independent from a running game. */ +public final class WhiteDiscUrlSmoke { + private static final String VIDEO_ID = "dQw4w9WgXcQ"; + + private WhiteDiscUrlSmoke() { + } + + public static void main(String[] args) { + require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected"); + require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected"); + require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected"); + require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected"); + require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected"); + + reject("http://www.youtube.com/watch?v=" + VIDEO_ID); + reject("https://youtube.example/watch?v=" + VIDEO_ID); + reject("https://youtube.com.evil.example/watch?v=" + VIDEO_ID); + reject("https://user@www.youtube.com/watch?v=" + VIDEO_ID); + reject("https://www.youtube.com:8443/watch?v=" + VIDEO_ID); + reject("https://www.youtube.com/watch?v=too_short"); + reject("https://www.youtube.com/watch?v=" + VIDEO_ID + "&v=" + VIDEO_ID); + reject("https://youtu.be/" + VIDEO_ID + "/extra"); + reject("https://www.youtube.com/watch?v=dQw4w9WgX%63Q"); + reject("x".repeat(WhiteDiscUrl.MAXIMUM_URL_LENGTH + 1)); + } + + private static String id(String url) { + return WhiteDiscUrl.videoId(url).orElseThrow(); + } + + private static void reject(String url) { + require(WhiteDiscUrl.videoId(url).isEmpty(), "Unsafe URL accepted: " + url); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/build.gradle b/build.gradle index 47fb807..b1d9558 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,15 @@ allprojects { } filter { includeGroup "maven.modrinth" } } + exclusiveContent { + forRepository { + maven { + name = "Lavalink" + url = "https://maven.lavalink.dev/releases" + } + } + filter { includeGroup "dev.lavalink.youtube" } + } } } @@ -4468,7 +4477,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") { def ouchManifest = new JsonSlurper().parse(file("ouch/src/main/resources/fabric.mod.json")) def rootBuild = file("build.gradle").text if (rootProject.pack_version != "26.2.0-alpha.210" - || rootProject.ambiance_version != "0.0.0-alpha.14" + || rootProject.ambiance_version != "0.0.0-alpha.15" || rootProject.ouch_version != "0.0.0-alpha.9" || migration.source?.pack_version != "26.2.0-alpha.199" || migration.source?.modules != [ambiance: "0.0.0-alpha.11", ouch: "0.0.0-alpha.8"] @@ -4488,7 +4497,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") { || migration.compatibility?.ouch_network_protocol != 1 || migration.compatibility?.save_migration_required != false || ambianceManifest.custom?.sanctuary26?.data_version != 2 - || ambianceManifest.custom?.sanctuary26?.network_protocol != 5 + || ambianceManifest.custom?.sanctuary26?.network_protocol != 6 || ouchManifest.custom?.sanctuary26?.data_version != 1 || ouchManifest.custom?.sanctuary26?.network_protocol != 1 || ouchManifest.depends?.ambiance != ">=0.0.0-alpha.12" @@ -5239,7 +5248,7 @@ tasks.register("verifyCanapliaFoundation") { "ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/api/visual/AmbianceVisualEffectApi.java").text if (rootProject.pack_version != "26.2.0-alpha.210" || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.ambiance_version != "0.0.0-alpha.14" + || rootProject.ambiance_version != "0.0.0-alpha.15" || migration.issue != 35 || migration.source?.fabric_id != "canaplia" || migration.source?.version != "2.0.0" @@ -5285,7 +5294,7 @@ tasks.register("verifyCanapliaFoundation") { || itsAliveManifest.custom?.sanctuary26?.data_version != 3 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 || ambianceManifest.custom?.sanctuary26?.data_version != 2 - || ambianceManifest.custom?.sanctuary26?.network_protocol != 5 + || ambianceManifest.custom?.sanctuary26?.network_protocol != 6 || !itsAliveDependencies.contains('implementation project(":ambiance")') || !itsAliveDependencies.contains('implementation project(":sanctuary")') || settingsText.contains('"canaplia"') @@ -5417,7 +5426,7 @@ tasks.register("verifyCanapliaGameplay") { || migration.compatibility?.pack_release_updated != false || itsAliveManifest.custom?.sanctuary26?.data_version != 3 || itsAliveManifest.custom?.sanctuary26?.network_protocol != 4 - || ambianceManifest.custom?.sanctuary26?.network_protocol != 5 + || ambianceManifest.custom?.sanctuary26?.network_protocol != 6 || discovery.schema_version != 1 || discovery.sources.size() > 128 || discovery.sources.any { it.chance_numerator < 1 || it.chance_numerator >= it.chance_denominator @@ -5488,7 +5497,7 @@ tasks.register("verifyCanapliaAlpha209Release") { itsalive: "0.0.0-alpha.33", onlyfun: "0.0.0-alpha.20"] if (rootProject.pack_version != "26.2.0-alpha.210" - || rootProject.ambiance_version != expectedTarget.ambiance + || rootProject.ambiance_version != "0.0.0-alpha.15" || rootProject.itsalive_version != "0.0.0-alpha.36" || rootProject.onlyfun_version != "0.0.0-alpha.21" || migration.source?.pack_version != "26.2.0-alpha.207" diff --git a/gradle.properties b/gradle.properties index 13a895c..8a030e2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ 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.14 +ambiance_version=0.0.0-alpha.15 ambiance_lifecycle=active redstoner_version=0.0.0-alpha.10 redstoner_lifecycle=active