diff --git a/src/main/java/com/lovetropics/extras/client/ClientPackControl.java b/src/main/java/com/lovetropics/extras/client/ClientPackControl.java new file mode 100644 index 0000000..74e7ab2 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/client/ClientPackControl.java @@ -0,0 +1,107 @@ +package com.lovetropics.extras.client; + +import com.google.common.collect.Sets; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.lovetropics.extras.data.packcontrol.PackControl; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.JsonOps; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackRepository; +import net.neoforged.fml.loading.FMLLoader; +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public class ClientPackControl { + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final Path STATE_PATH = FMLLoader.getGamePath().resolve("config/client_pack_control.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private static CompletableFuture stateFuture; + + static { + stateFuture = CompletableFuture.supplyAsync(ClientPackControl::readState).exceptionally(throwable -> { + LOGGER.error("Failed to read pack control state", throwable); + return PackControl.State.DEFAULT; + }); + } + + public static void updatePacks(PackControl.State state) { + PackControl.State oldState = state(); + if (oldState.equals(state)) { + return; + } + handleStateChange(Minecraft.getInstance(), oldState, state); + stateFuture = CompletableFuture.completedFuture(state); + Util.ioPool().submit(() -> storeState(state)); + } + + private static void handleStateChange(Minecraft minecraft, PackControl.State oldState, PackControl.State newState) { + PackRepository packRepository = minecraft.getResourcePackRepository(); + + Set newlyEnabled = Sets.difference(newState.enabled(), oldState.enabled()); + Set newlyDisabled = Sets.difference(oldState.enabled(), newState.enabled()); + + newlyEnabled.forEach(packRepository::addPack); + newlyDisabled.forEach(packRepository::removePack); + + minecraft.options.updateResourcePacks(packRepository); + } + + private static PackControl.State readState() { + if (!Files.exists(STATE_PATH)) { + return PackControl.State.DEFAULT; + } + try (BufferedReader reader = Files.newBufferedReader(STATE_PATH)) { + return PackControl.State.CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(reader)).getOrThrow(); + } catch (IOException e) { + throw new CompletionException(e); + } + } + + private static void storeState(PackControl.State state) { + try (BufferedWriter writer = Files.newBufferedWriter(STATE_PATH)) { + JsonElement json = PackControl.State.CODEC.encodeStart(JsonOps.INSTANCE, state).getOrThrow(); + GSON.toJson(json, writer); + } catch (Exception e) { + LOGGER.error("Failed to write pack control state", e); + } + } + + public static PackControl.State state() { + return stateFuture.join(); + } + + public static Collection removeHidden(Collection packs, PackRepository repository) { + PackControl.State state = state(); + if (state.hidden().isEmpty()) { + return packs; + } + List newPacks = new ArrayList<>(packs); + Collection selectedIds = repository.getSelectedIds(); + if (newPacks.removeIf(pack -> { + // Let the player keep it if they already had it enabled + String id = pack.getId(); + return state.hidden().contains(id) && !selectedIds.contains(id); + })) { + return newPacks; + } + return packs; + } +} diff --git a/src/main/java/com/lovetropics/extras/data/packcontrol/PackControl.java b/src/main/java/com/lovetropics/extras/data/packcontrol/PackControl.java new file mode 100644 index 0000000..c426842 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/data/packcontrol/PackControl.java @@ -0,0 +1,138 @@ +package com.lovetropics.extras.data.packcontrol; + +import com.lovetropics.extras.LTExtras; +import com.lovetropics.extras.network.message.ClientboundUpdatePackControl; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.netty.buffer.ByteBuf; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.saveddata.SavedData; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.UnaryOperator; + +import static com.mojang.brigadier.arguments.StringArgumentType.getString; +import static com.mojang.brigadier.arguments.StringArgumentType.string; +import static net.minecraft.commands.Commands.argument; +import static net.minecraft.commands.Commands.literal; + +@EventBusSubscriber(modid = LTExtras.MODID) +public class PackControl extends SavedData { + private static final Factory FACTORY = new Factory<>(PackControl::new, PackControl::load); + + private static final String STORAGE_ID = LTExtras.MODID + "_pack_control"; + + private State state = State.DEFAULT; + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + tag.put("state", State.CODEC.encodeStart(NbtOps.INSTANCE, state).getOrThrow()); + return tag; + } + + public static PackControl get(MinecraftServer server) { + return server.overworld().getDataStorage().computeIfAbsent(FACTORY, STORAGE_ID); + } + + private static PackControl load(CompoundTag tag, HolderLookup.Provider registries) { + PackControl packControl = new PackControl(); + State.CODEC.parse(NbtOps.INSTANCE, tag.get("state")).ifSuccess(state -> packControl.state = state); + return packControl; + } + + @SubscribeEvent + public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + PackControl packControl = PackControl.get(player.getServer()); + player.connection.send(new ClientboundUpdatePackControl(packControl.state)); + } + } + + @SubscribeEvent + public static void onRegisterCommands(RegisterCommandsEvent event) { + event.getDispatcher().register(literal("packcontrol") + .requires(source -> source.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(packUpdater("enable", (state, packId) -> state.setEnabled(packId, true))) + .then(packUpdater("disable", (state, packId) -> state.setEnabled(packId, false))) + .then(packUpdater("hide", (state, packId) -> state.setHidden(packId, true))) + .then(packUpdater("show", (state, packId) -> state.setHidden(packId, false))) + ); + } + + private static LiteralArgumentBuilder packUpdater(String name, BiFunction updater) { + return literal(name) + .then(argument("pack", string()) + .executes(context -> { + String pack = getString(context, "pack"); + updateState(context.getSource().getServer(), state1 -> updater.apply(state1, pack)); + return 1; + }) + ); + } + + public static void updateState(MinecraftServer server, UnaryOperator operator) { + PackControl packControl = PackControl.get(server); + State newState = operator.apply(packControl.state); + if (packControl.state.equals(newState)) { + return; + } + packControl.state = newState; + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + player.connection.send(new ClientboundUpdatePackControl(newState)); + } + packControl.setDirty(); + } + + public record State(Set hidden, Set enabled) { + public static final State DEFAULT = new State(Set.of(), Set.of()); + + private static final Codec> PACK_SET_CODEC = Codec.STRING.listOf().xmap(Set::copyOf, List::copyOf); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + PACK_SET_CODEC.fieldOf("hidden").forGetter(State::hidden), + PACK_SET_CODEC.fieldOf("enabled").forGetter(State::enabled) + ).apply(i, State::new)); + + private static final StreamCodec> PACK_SET_STREAM_CODEC = ByteBufCodecs.STRING_UTF8.apply(ByteBufCodecs.collection(HashSet::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + PACK_SET_STREAM_CODEC, State::hidden, + PACK_SET_STREAM_CODEC, State::enabled, + State::new + ); + + public State setHidden(String packId, boolean hidden) { + return new State(setInSet(this.hidden, packId, hidden), enabled); + } + + public State setEnabled(String packId, boolean enabled) { + return new State(this.hidden, setInSet(this.enabled, packId, enabled)); + } + + private static Set setInSet(Set set, T value, boolean inSet) { + Set newSet = new HashSet<>(set); + if (inSet) { + newSet.add(value); + } else { + newSet.remove(value); + } + return newSet; + } + } +} diff --git a/src/main/java/com/lovetropics/extras/data/packcontrol/package-info.java b/src/main/java/com/lovetropics/extras/data/packcontrol/package-info.java new file mode 100644 index 0000000..2afec72 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/data/packcontrol/package-info.java @@ -0,0 +1,9 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +@FieldsAreNonnullByDefault +package com.lovetropics.extras.data.packcontrol; + +import com.mojang.blaze3d.FieldsAreNonnullByDefault; +import com.mojang.blaze3d.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/PackSelectionModelMixin.java b/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/PackSelectionModelMixin.java new file mode 100644 index 0000000..638a310 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/PackSelectionModelMixin.java @@ -0,0 +1,25 @@ +package com.lovetropics.extras.mixin.client.packcontrol; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.lovetropics.extras.client.ClientPackControl; +import net.minecraft.client.gui.screens.packs.PackSelectionModel; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackRepository; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import java.util.Collection; + +@Mixin(PackSelectionModel.class) +public class PackSelectionModelMixin { + @WrapOperation(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/packs/repository/PackRepository;getAvailablePacks()Ljava/util/Collection;")) + private Collection initGetAvailablePacks(PackRepository repository, Operation> original) { + return ClientPackControl.removeHidden(original.call(repository), repository); + } + + @WrapOperation(method = "findNewPacks", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/packs/repository/PackRepository;getAvailablePacks()Ljava/util/Collection;")) + private Collection findNewPacksGetAvailablePacks(PackRepository repository, Operation> original) { + return ClientPackControl.removeHidden(original.call(repository), repository); + } +} diff --git a/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/package-info.java b/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/package-info.java new file mode 100644 index 0000000..be8deda --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/client/packcontrol/package-info.java @@ -0,0 +1,9 @@ +@ParametersAreNonnullByDefault +@MethodsReturnNonnullByDefault +@FieldsAreNonnullByDefault +package com.lovetropics.extras.mixin.client.packcontrol; + +import com.mojang.blaze3d.FieldsAreNonnullByDefault; +import com.mojang.blaze3d.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java b/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java index b572b39..95c575f 100644 --- a/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java +++ b/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java @@ -5,6 +5,7 @@ import com.lovetropics.extras.network.message.ClientboundOpenCollectibleBasketPacket; import com.lovetropics.extras.network.message.ClientboundPoiFacesPacket; import com.lovetropics.extras.network.message.ClientboundRemovePoiPacket; +import com.lovetropics.extras.network.message.ClientboundUpdatePackControl; import com.lovetropics.extras.network.message.ClientboundUpdatePoiPacket; import com.lovetropics.extras.network.message.ClientboundSetAutoRejoinIntent; import com.lovetropics.extras.network.message.ClientboundSetDisplayTextPacket; @@ -36,5 +37,6 @@ public static void registerPackets(RegisterPayloadHandlersEvent event) { registrar.playToClient(ClientboundRemovePoiPacket.TYPE, ClientboundRemovePoiPacket.STREAM_CODEC, ClientboundRemovePoiPacket::handle); registrar.playToClient(ClientboundPoiFacesPacket.TYPE, ClientboundPoiFacesPacket.STREAM_CODEC, ClientboundPoiFacesPacket::handle); registrar.playToClient(ClientboundOpenCollectibleBasketPacket.TYPE, ClientboundOpenCollectibleBasketPacket.STREAM_CODEC, ClientboundOpenCollectibleBasketPacket::handle); + registrar.playToClient(ClientboundUpdatePackControl.TYPE, ClientboundUpdatePackControl.STREAM_CODEC, ClientboundUpdatePackControl::handle); } } diff --git a/src/main/java/com/lovetropics/extras/network/message/ClientboundUpdatePackControl.java b/src/main/java/com/lovetropics/extras/network/message/ClientboundUpdatePackControl.java new file mode 100644 index 0000000..82a38f3 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/network/message/ClientboundUpdatePackControl.java @@ -0,0 +1,26 @@ +package com.lovetropics.extras.network.message; + +import com.lovetropics.extras.LTExtras; +import com.lovetropics.extras.client.ClientPackControl; +import com.lovetropics.extras.data.packcontrol.PackControl; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +public record ClientboundUpdatePackControl( + PackControl.State state +) implements CustomPacketPayload { + public static final StreamCodec STREAM_CODEC = PackControl.State.STREAM_CODEC.map(ClientboundUpdatePackControl::new, ClientboundUpdatePackControl::state); + + public static final Type TYPE = new Type<>(LTExtras.location("update_resource_packs")); + + public static void handle(ClientboundUpdatePackControl packet, IPayloadContext context) { + ClientPackControl.updatePacks(packet.state); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/resources/ltextras.mixins.json b/src/main/resources/ltextras.mixins.json index e53e73b..a1f2189 100644 --- a/src/main/resources/ltextras.mixins.json +++ b/src/main/resources/ltextras.mixins.json @@ -39,15 +39,16 @@ "client.collectible.MultiPlayerGameModeMixin", "client.menu.LogoRendererMixin", "client.menu.TitleScreenMixin", + "client.packcontrol.PackSelectionModelMixin", "client.perf.BlockModelRendererCacheMixin", "client.perf.ChunkRenderMixin", "client.perf.ClientChunkCacheStorageMixin", "client.perf.ClientWorldMixin", + "client.perf.TagCollectorMixin", "client.perf.ViewFrustumAccess", "client.perf.WorldRendererAccess", "client.translation.MinecraftMixin", - "client.world_effect.ClientLevelMixin", - "client.perf.TagCollectorMixin" + "client.world_effect.ClientLevelMixin" ], "injectors": { "defaultRequire": 1