feat(ambiance): ajouter le disque blanc YouTube (#62) #75

Merged
koka merged 3 commits from feature/white-disc-youtube into main 2026-08-29 16:30:44 +00:00
30 changed files with 830 additions and 23 deletions
+4 -1
View File
@@ -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 laffichage 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 linsérer dans un jukebox ;
- valide strictement lhôte et lidentifiant vidéo côté serveur, ne transmet quun 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`
+1 -1
View File
@@ -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 |
+31
View File
@@ -0,0 +1,31 @@
# Disque blanc YouTube
Le disque blanc appartient à **Ambiance**, propriétaire de laudio positionnel. Il conserve lID
`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 linsérer dans un
jukebox vanilla.
## Contrat réseau et sécurité
- le serveur reste lautorité de linsertion 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 lidentifiant
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 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.
## 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 dorigine. Laccè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 nest
acceptée par le serveur.
+86 -3
View File
@@ -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")
}
@@ -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();
@@ -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();
@@ -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();
}
}
@@ -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<Long, Long> REQUESTS = new HashMap<>();
private static final LinkedHashMap<Long, Playback> 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<Long, Playback> 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<Level> dimension,
ChannelAccess.ChannelHandle handle,
LavaplayerAudioStream stream, long[] finishedAt) {
private Playback(BlockPos pos, ResourceKey<Level> 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];
}
}
}
@@ -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<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.ambiance.white_disc")
.withStyle(ChatFormatting.GRAY));
}
}
@@ -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);
}
}
@@ -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();
}
@@ -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();
}
@@ -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))
);
@@ -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<WhiteDiscPlaybackPayload> ID =
new Type<>(AmbianceMod.id("white_disc_playback"));
public static final StreamCodec<RegistryFriendlyByteBuf, WhiteDiscPlaybackPayload> 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<? extends CustomPacketPayload> type() {
return ID;
}
}
@@ -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<JukeboxSong> WHITE_DISC_SONG = ResourceKey.create(
Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc"));
private static final ResourceKey<Item> WHITE_DISC_KEY = ResourceKey.create(
Registries.ITEM, AmbianceMod.id("white_disc"));
private static final ResourceKey<CreativeModeTab> 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);
}
}
@@ -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<String> 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<String> 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<ServerPlayer> 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);
}
}
}
}
@@ -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<String> LONG_HOSTS = Set.of(
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com");
private WhiteDiscUrl() {
}
public static Optional<String> 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<String> valid(String candidate) {
return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty();
}
}
@@ -11,6 +11,9 @@
"GameRendererMixin",
"LightCoordsUtilMixin",
"LightmapRenderStateExtractorMixin",
"LevelRendererMixin",
"SoundEngineAccessor",
"SoundManagerAccessor",
"SkyRendererMixin"
],
"injectors": {
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "ambiance:item/white_disc"
}
}
@@ -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."
}
@@ -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 daccessibilité visuelle"
"key.ambiance.visual_settings": "Réglages daccessibilité 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."
}
@@ -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 минут."
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/template_music_disc",
"textures": {
"layer0": "ambiance:item/white_disc"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,8 @@
{
"comparator_output": 15,
"description": {
"translate": "jukebox_song.ambiance.white_disc"
},
"length_in_seconds": 1800.0,
"sound_event": "minecraft:intentionally_empty"
}
@@ -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"
}
}
+5 -5
View File
@@ -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."
}
}
}
@@ -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);
}
}
+15 -6
View File
@@ -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"
+1 -1
View File
@@ -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