From 03a372ab36fa3fb70612fe8067dca0e5884b45b9 Mon Sep 17 00:00:00 2001 From: Gegy Date: Sat, 14 Oct 2023 21:14:54 +0200 Subject: [PATCH] Expose placeholders for stream schedule --- build.gradle | 1 + gradle.properties | 1 + .../com/lovetropics/extras/ExtrasConfig.java | 45 ++++++++ .../java/com/lovetropics/extras/LTExtras.java | 5 + .../extras/collectible/CollectibleStore.java | 3 +- .../extras/network/LTExtrasNetwork.java | 6 + .../extras/network/SetTimeZonePacket.java | 34 ++++++ .../extras/schedule/PlayerTimeZone.java | 68 +++++++++++ .../extras/schedule/SchedulePlaceholders.java | 109 ++++++++++++++++++ .../extras/schedule/StreamSchedule.java | 93 +++++++++++++++ .../extras/schedule/TimeZoneSender.java | 19 +++ 11 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/lovetropics/extras/ExtrasConfig.java create mode 100644 src/main/java/com/lovetropics/extras/network/SetTimeZonePacket.java create mode 100644 src/main/java/com/lovetropics/extras/schedule/PlayerTimeZone.java create mode 100644 src/main/java/com/lovetropics/extras/schedule/SchedulePlaceholders.java create mode 100644 src/main/java/com/lovetropics/extras/schedule/StreamSchedule.java create mode 100644 src/main/java/com/lovetropics/extras/schedule/TimeZoneSender.java diff --git a/build.gradle b/build.gradle index 7efdce7c..3e0f5d34 100644 --- a/build.gradle +++ b/build.gradle @@ -108,6 +108,7 @@ dependencies { } jarJar(implementation(fg.deobf("com.lovetropics.lib:LTLib:$ltlib_version"))) + jarJar(implementation(fg.deobf("eu.pb4:placeholder-api:$placeholder_api_version"))) // runtimeOnly fg.deobf('com.jozufozu.flywheel:Flywheel-Forge:1.18-0.7.0.70') // runtimeOnly fg.deobf('com.simibubi.create:Create:mc1.18.2_v0.4.1+113') diff --git a/gradle.properties b/gradle.properties index 4ac48274..c60dbfa8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,7 @@ parchment_version=2023.06.26-1.20.1 registrate_version=MC1.20-1.3.3 ltlib_version=[1.3,1.4) +placeholder_api_version=[2.1,2.2) # Sets default memory used for gradle commands. Can be overridden by user or command line properties. # This is required to provide enough memory for the Minecraft decompilation process. diff --git a/src/main/java/com/lovetropics/extras/ExtrasConfig.java b/src/main/java/com/lovetropics/extras/ExtrasConfig.java new file mode 100644 index 00000000..adf5926f --- /dev/null +++ b/src/main/java/com/lovetropics/extras/ExtrasConfig.java @@ -0,0 +1,45 @@ +package com.lovetropics.extras; + +import net.minecraftforge.common.ForgeConfigSpec; +import net.minecraftforge.common.ForgeConfigSpec.Builder; +import net.minecraftforge.common.ForgeConfigSpec.ConfigValue; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod.EventBusSubscriber; +import net.minecraftforge.fml.common.Mod.EventBusSubscriber.Bus; +import net.minecraftforge.fml.event.config.ModConfigEvent; + +@EventBusSubscriber(modid = LTExtras.MODID, bus = Bus.MOD) +public class ExtrasConfig { + private static final Builder COMMON_BUILDER = new Builder(); + + public static final CategoryTechStack TECH_STACK = new CategoryTechStack(); + + public static final class CategoryTechStack { + public final ConfigValue authKey; + public final ConfigValue scheduleUrl; + + private CategoryTechStack() { + COMMON_BUILDER.comment("Connection to the tech stack").push("techStack"); + + authKey = COMMON_BUILDER + .comment("API Key used to allow authentication with the tech stack") + .define("authKey", ""); + + scheduleUrl = COMMON_BUILDER + .comment("API URL to get stream schedule from") + .define("schedule", "http://localhost/schedule"); + + COMMON_BUILDER.pop(); + } + } + + public static final ForgeConfigSpec COMMON_CONFIG = COMMON_BUILDER.build(); + + @SubscribeEvent + public static void configLoad(final ModConfigEvent.Loading event) { + } + + @SubscribeEvent + public static void configReload(final ModConfigEvent.Reloading event) { + } +} diff --git a/src/main/java/com/lovetropics/extras/LTExtras.java b/src/main/java/com/lovetropics/extras/LTExtras.java index 098ff19a..87625ec3 100644 --- a/src/main/java/com/lovetropics/extras/LTExtras.java +++ b/src/main/java/com/lovetropics/extras/LTExtras.java @@ -10,6 +10,7 @@ import com.lovetropics.extras.effect.ExtraEffects; import com.lovetropics.extras.entity.ExtraEntities; import com.lovetropics.extras.network.LTExtrasNetwork; +import com.lovetropics.extras.schedule.PlayerTimeZone; import com.mojang.brigadier.CommandDispatcher; import com.tterrag.registrate.Registrate; import com.tterrag.registrate.providers.ProviderType; @@ -38,6 +39,7 @@ import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.ModLoadingContext; import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.config.ModConfig; import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.util.ObfuscationReflectionHelper; @@ -56,6 +58,7 @@ public class LTExtras { public static final Capability COLLECTIBLE_STORE = CapabilityManager.get(new CapabilityToken<>() {}); public static final Capability SPAWN_ITEMS_STORE = CapabilityManager.get(new CapabilityToken<>() {}); + public static final Capability PLAYER_TIME_ZONE = CapabilityManager.get(new CapabilityToken<>() {}); public static Registrate registrate() { return REGISTRATE.get(); @@ -110,6 +113,8 @@ public LTExtras() { ) ); }); + + ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, ExtrasConfig.COMMON_CONFIG); } private static final Pattern QUALIFIER = Pattern.compile("-\\w+\\+\\d+"); diff --git a/src/main/java/com/lovetropics/extras/collectible/CollectibleStore.java b/src/main/java/com/lovetropics/extras/collectible/CollectibleStore.java index 5c985a30..8d867395 100644 --- a/src/main/java/com/lovetropics/extras/collectible/CollectibleStore.java +++ b/src/main/java/com/lovetropics/extras/collectible/CollectibleStore.java @@ -77,7 +77,8 @@ public static CollectibleStore get(final Player player) { return player.getCapability(LTExtras.COLLECTIBLE_STORE).orElseThrow(IllegalStateException::new); } - @Nullable public static CollectibleStore getNullable(final Player player) { + @Nullable + public static CollectibleStore getNullable(final Player player) { return player.getCapability(LTExtras.COLLECTIBLE_STORE).orElse(null); } diff --git a/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java b/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java index fa68b93b..e74fbb9c 100644 --- a/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java +++ b/src/main/java/com/lovetropics/extras/network/LTExtrasNetwork.java @@ -33,6 +33,12 @@ public static void register() { .decoder(ReturnCollectibleItemPacket::new) .consumerMainThread(ReturnCollectibleItemPacket::handle) .add(); + + CHANNEL.messageBuilder(SetTimeZonePacket.class, 3, NetworkDirection.PLAY_TO_SERVER) + .encoder(SetTimeZonePacket::write) + .decoder(SetTimeZonePacket::read) + .consumerMainThread(SetTimeZonePacket::handle) + .add(); } } diff --git a/src/main/java/com/lovetropics/extras/network/SetTimeZonePacket.java b/src/main/java/com/lovetropics/extras/network/SetTimeZonePacket.java new file mode 100644 index 00000000..2aa181bb --- /dev/null +++ b/src/main/java/com/lovetropics/extras/network/SetTimeZonePacket.java @@ -0,0 +1,34 @@ +package com.lovetropics.extras.network; + +import com.lovetropics.extras.schedule.PlayerTimeZone; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.network.NetworkEvent; + +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.function.Supplier; + +public record SetTimeZonePacket(ZoneId id) { + private static final int MAX_LENGTH = 64; + + public static SetTimeZonePacket read(final FriendlyByteBuf input) { + try { + return new SetTimeZonePacket(ZoneId.of(input.readUtf(MAX_LENGTH))); + } catch (final DateTimeException e) { + return new SetTimeZonePacket(ZoneOffset.UTC); + } + } + + public void write(final FriendlyByteBuf output) { + output.writeUtf(id.getId()); + } + + public void handle(final Supplier ctx) { + final ServerPlayer player = ctx.get().getSender(); + if (player != null) { + PlayerTimeZone.set(player, id); + } + } +} diff --git a/src/main/java/com/lovetropics/extras/schedule/PlayerTimeZone.java b/src/main/java/com/lovetropics/extras/schedule/PlayerTimeZone.java new file mode 100644 index 00000000..a7defb8d --- /dev/null +++ b/src/main/java/com/lovetropics/extras/schedule/PlayerTimeZone.java @@ -0,0 +1,68 @@ +package com.lovetropics.extras.schedule; + +import com.lovetropics.extras.LTExtras; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.ICapabilityProvider; +import net.minecraftforge.common.util.LazyOptional; +import net.minecraftforge.event.AttachCapabilitiesEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import javax.annotation.Nullable; +import java.time.ZoneId; +import java.time.ZoneOffset; + +@Mod.EventBusSubscriber(modid = LTExtras.MODID) +public class PlayerTimeZone implements ICapabilityProvider { + public static final ResourceLocation ID = new ResourceLocation(LTExtras.MODID, "time_zone"); + + private final LazyOptional instance = LazyOptional.of(() -> this); + + private ZoneId zoneId = ZoneOffset.UTC; + + @SubscribeEvent + public static void onAttachEntityCapabilities(final AttachCapabilitiesEvent event) { + if (event.getObject() instanceof ServerPlayer) { + event.addCapability(ID, new PlayerTimeZone()); + } + } + + @SubscribeEvent + public static void onPlayerClone(final PlayerEvent.Clone event) { + if (event.isWasDeath()) { + final PlayerTimeZone oldTimeZone = getOrNull(event.getOriginal()); + final PlayerTimeZone newTimeZone = getOrNull(event.getEntity()); + if (oldTimeZone != null && newTimeZone != null) { + newTimeZone.zoneId = oldTimeZone.zoneId; + } + } + } + + public static void set(final ServerPlayer player, final ZoneId zone) { + final PlayerTimeZone capability = getOrNull(player); + if (capability != null) { + capability.zoneId = zone; + } + } + + public static ZoneId get(final ServerPlayer player) { + final PlayerTimeZone capability = getOrNull(player); + return capability != null ? capability.zoneId : ZoneOffset.UTC; + } + + @Nullable + private static PlayerTimeZone getOrNull(final Player player) { + return player.getCapability(LTExtras.PLAYER_TIME_ZONE).orElse(null); + } + + @Override + public LazyOptional getCapability(final Capability cap, @Nullable final Direction side) { + return LTExtras.PLAYER_TIME_ZONE.orEmpty(cap, instance); + } +} diff --git a/src/main/java/com/lovetropics/extras/schedule/SchedulePlaceholders.java b/src/main/java/com/lovetropics/extras/schedule/SchedulePlaceholders.java new file mode 100644 index 00000000..e27200af --- /dev/null +++ b/src/main/java/com/lovetropics/extras/schedule/SchedulePlaceholders.java @@ -0,0 +1,109 @@ +package com.lovetropics.extras.schedule; + +import com.lovetropics.extras.LTExtras; +import eu.pb4.placeholders.api.PlaceholderContext; +import eu.pb4.placeholders.api.PlaceholderResult; +import eu.pb4.placeholders.api.Placeholders; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import javax.annotation.Nullable; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +@Mod.EventBusSubscriber(modid = LTExtras.MODID) +public class SchedulePlaceholders { + private static final PlaceholderResult UNKNOWN = PlaceholderResult.value("?"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE HH:mm"); + + private static final Duration FETCH_INTERVAL = Duration.ofMinutes(5); + + private static CompletableFuture fetchFuture = CompletableFuture.completedFuture(null); + @Nullable + private static StreamSchedule schedule; + private static Instant lastFetchTime = Instant.EPOCH; + + static { + registerPlaceholder("current/title", (ctx, current, next) -> PlaceholderResult.value(current.shortDescription())); + registerPlaceholder("current/description", (ctx, current, next) -> PlaceholderResult.value(current.longDescription())); + registerPlaceholder("current/hosts", (ctx, current, next) -> formatHosts(current)); + registerPlaceholder("current/start", (ctx, current, next) -> formatLocalTime(ctx, current.time())); + registerPlaceholder("current/end", (ctx, current, next) -> next != null ? formatLocalTime(ctx, next.time()) : UNKNOWN); + + registerPlaceholderNext("next/title", (ctx, next) -> PlaceholderResult.value(next.shortDescription())); + registerPlaceholderNext("next/description", (ctx, next) -> PlaceholderResult.value(next.longDescription())); + registerPlaceholderNext("next/hosts", (ctx, next) -> formatHosts(next)); + registerPlaceholderNext("next/start", (ctx, next) -> formatLocalTime(ctx, next.time())); + registerPlaceholderNext("next/time_until", (ctx, next) -> formatTimeUntil(next)); + } + + private static void registerPlaceholder(final String id, final PlaceholderFunction function) { + Placeholders.register(new ResourceLocation(LTExtras.MODID, "schedule/" + id), (ctx, arg) -> { + final StreamSchedule schedule = SchedulePlaceholders.schedule; + if (schedule == null) { + return UNKNOWN; + } + final StreamSchedule.State state = schedule.stateAt(Instant.now()); + if (state != null) { + return function.get(ctx, state.currentEntry(), state.nextEntry()); + } + return UNKNOWN; + }); + } + + private static void registerPlaceholderNext(final String id, final BiFunction function) { + registerPlaceholder(id, (ctx, current, next) -> next != null ? function.apply(ctx, next) : UNKNOWN); + } + + private static PlaceholderResult formatHosts(final StreamSchedule.Entry entry) { + return PlaceholderResult.value(entry.hosts().stream() + .map(StreamSchedule.Host::name) + .collect(Collectors.joining(", ")) + ); + } + + private static PlaceholderResult formatTimeUntil(final StreamSchedule.Entry entry) { + Duration duration = Duration.between(Instant.now(), entry.time()); + if (duration.isNegative()) { + duration = Duration.ZERO; + } + return PlaceholderResult.value(duration.toMinutes() + " minutes"); + } + + private static PlaceholderResult formatLocalTime(final PlaceholderContext ctx, final Instant time) { + final LocalDateTime localTime = time.atZone(getTimeZone(ctx)).toLocalDateTime(); + return PlaceholderResult.value(TIME_FORMATTER.format(localTime)); + } + + private static ZoneId getTimeZone(final PlaceholderContext ctx) { + final ServerPlayer player = ctx.player(); + return player != null ? PlayerTimeZone.get(player) : ZoneOffset.UTC; + } + + @SubscribeEvent + public static void onServerTick(final TickEvent.ServerTickEvent event) { + if (event.phase == TickEvent.Phase.END) { + return; + } + + if (!fetchFuture.isDone()) { + return; + } + + final Instant time = Instant.now(); + if (Duration.between(lastFetchTime, time).compareTo(FETCH_INTERVAL) > 0) { + fetchFuture = StreamSchedule.fetch().thenAccept(opt -> opt.ifPresent(s -> schedule = s)); + lastFetchTime = time; + } + } + + private interface PlaceholderFunction { + PlaceholderResult get(PlaceholderContext ctx, StreamSchedule.Entry current, @Nullable StreamSchedule.Entry next); + } +} diff --git a/src/main/java/com/lovetropics/extras/schedule/StreamSchedule.java b/src/main/java/com/lovetropics/extras/schedule/StreamSchedule.java new file mode 100644 index 00000000..69cd7e7c --- /dev/null +++ b/src/main/java/com/lovetropics/extras/schedule/StreamSchedule.java @@ -0,0 +1,93 @@ +package com.lovetropics.extras.schedule; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.lovetropics.extras.ExtrasConfig; +import com.lovetropics.lib.codec.MoreCodecs; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.Util; +import org.apache.http.HttpHeaders; +import org.slf4j.Logger; + +import javax.annotation.Nullable; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public record StreamSchedule(List entries) { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .executor(Util.ioPool()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Entry.CODEC.listOf().fieldOf("schedule_entries").forGetter(StreamSchedule::entries) + ).apply(i, StreamSchedule::new)); + + public static CompletableFuture> fetch() { + final String authKey = ExtrasConfig.TECH_STACK.authKey.get(); + if (authKey.isEmpty()) { + return CompletableFuture.completedFuture(Optional.empty()); + } + final HttpRequest request = HttpRequest.newBuilder(URI.create(ExtrasConfig.TECH_STACK.scheduleUrl.get())) + .header("Authorization", "Bearer " + authKey) + .header(HttpHeaders.USER_AGENT, "LTExtras 1.0 (lovetropics.org)") + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .GET() + .build(); + return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApplyAsync(response -> { + final JsonElement json = JsonParser.parseString(response.body()); + return CODEC.parse(JsonOps.INSTANCE, json).resultOrPartial(Util.prefix("Failed to parse stream schedule: ", LOGGER::error)); + }, Util.backgroundExecutor()) + .exceptionally(throwable -> { + LOGGER.error("Failed to fetch stream schedule", throwable); + return Optional.empty(); + }); + } + + @Nullable + public State stateAt(final Instant time) { + for (int i = 0; i < entries.size(); i++) { + final Entry entry = entries.get(i); + if (!entry.time().isBefore(time)) { + final Entry nextEntry = i + 1 < entries.size() ? entries.get(i + 1) : null; + return new State(entry, nextEntry); + } + } + return null; + } + + public record Entry(String shortDescription, String longDescription, Instant time, List hosts) { + private static final Codec TIME_CODEC = MoreCodecs.localDateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")).xmap( + localTime -> localTime.atOffset(ZoneOffset.UTC).toInstant(), + instant -> instant.atOffset(ZoneOffset.UTC).toLocalDateTime() + ); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.fieldOf("short_desc").forGetter(Entry::shortDescription), + Codec.STRING.optionalFieldOf("long_desc", "").forGetter(Entry::longDescription), + TIME_CODEC.fieldOf("time").forGetter(Entry::time), + Host.CODEC.listOf().fieldOf("hosts").forGetter(Entry::hosts) + ).apply(i, Entry::new)); + } + + public record Host(String name) { + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.fieldOf("name").forGetter(Host::name) + ).apply(i, Host::new)); + } + + public record State(Entry currentEntry, @Nullable Entry nextEntry) { + } +} diff --git a/src/main/java/com/lovetropics/extras/schedule/TimeZoneSender.java b/src/main/java/com/lovetropics/extras/schedule/TimeZoneSender.java new file mode 100644 index 00000000..51f257db --- /dev/null +++ b/src/main/java/com/lovetropics/extras/schedule/TimeZoneSender.java @@ -0,0 +1,19 @@ +package com.lovetropics.extras.schedule; + +import com.lovetropics.extras.LTExtras; +import com.lovetropics.extras.network.LTExtrasNetwork; +import com.lovetropics.extras.network.SetTimeZonePacket; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.ClientPlayerNetworkEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.time.ZoneId; + +@Mod.EventBusSubscriber(modid = LTExtras.MODID, value = Dist.CLIENT) +public class TimeZoneSender { + @SubscribeEvent + public static void onLogIn(final ClientPlayerNetworkEvent.LoggingIn event) { + LTExtrasNetwork.CHANNEL.sendToServer(new SetTimeZonePacket(ZoneId.systemDefault())); + } +}