diff --git a/gradle.properties b/gradle.properties index c9bc6a5..2f7957e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.configuration-cache=false # Mod mod_id=video_player -mod_version=0.4.16 +mod_version=0.4.17 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java index e1c9e08..6911ae7 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerClient.java @@ -1,6 +1,7 @@ package com.ejclaw.videoplayer; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; +import com.ejclaw.videoplayer.client.MusicQuizClient; import com.ejclaw.videoplayer.client.net.ClientNetworking; import com.ejclaw.videoplayer.client.playback.VideoPlayback; import com.ejclaw.videoplayer.client.render.VideoAnchorRenderer; @@ -28,6 +29,7 @@ public class VideoPlayerClient implements ClientModInitializer { @Override public void onInitializeClient() { ClientNetworking.register(); + MusicQuizClient.register(); BlockEntityRendererRegistry.register( VideoPlayerBlockEntities.VIDEO_ANCHOR, diff --git a/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java b/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java index 3a99479..6fe3a6d 100644 --- a/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java +++ b/src/main/java/com/ejclaw/videoplayer/VideoPlayerMod.java @@ -8,6 +8,7 @@ import com.ejclaw.videoplayer.command.VideoStickCommand; import com.ejclaw.videoplayer.net.CachePolicyPayload; import com.ejclaw.videoplayer.net.PreloadPayload; import com.ejclaw.videoplayer.net.VideoPlayerNetwork; +import com.ejclaw.videoplayer.server.MusicQuizPresence; import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities; import com.ejclaw.videoplayer.registry.VideoPlayerBlocks; import com.ejclaw.videoplayer.registry.VideoPlayerItems; @@ -30,6 +31,7 @@ public class VideoPlayerMod implements ModInitializer { VideoPlayerNetwork.registerPayloadTypes(); VideoPlayerNetwork.registerServerReceivers(); + MusicQuizPresence.register(); VideoPlayerConfig.load(); diff --git a/src/main/java/com/ejclaw/videoplayer/client/MusicQuizClient.java b/src/main/java/com/ejclaw/videoplayer/client/MusicQuizClient.java new file mode 100644 index 0000000..025af26 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/client/MusicQuizClient.java @@ -0,0 +1,56 @@ +package com.ejclaw.videoplayer.client; + +import com.ejclaw.videoplayer.net.MqHelloPayload; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; + +/** + * Client side of the {@code music_quiz} datapack handshake + * ({@code docs/mc_video_player_mod_integration.md}). Sends one {@link MqHelloPayload} + * on JOIN, then repeats every 100 client ticks (~5 s at 20 tps). + * + *

The periodic resend is mandatory: the datapack's + * {@code mq:players/login} resets the per-player score to 0 the moment the player + * passes the spawn dialog. The single JOIN-time send can land before that reset, + * leaving the score at 0 and breaking the {@code mq:commands/start} guard. With a + * 5-second resend, the score is restored to 1 within at most one cycle. + */ +@Environment(EnvType.CLIENT) +public final class MusicQuizClient { + private MusicQuizClient() {} + + /** ~5 s at 20 tps. Spec recommends ≤5 s; tighter is fine but unnecessary. */ + private static final int RESEND_INTERVAL_TICKS = 100; + + private static int tickCounter = 0; + + public static void register() { + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> + send()); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + // Gate on world presence — when the player isn't in-game, level is null and the + // counter resets so the next session starts fresh instead of firing immediately. + if (client.level == null) { + tickCounter = 0; + return; + } + tickCounter++; + if (tickCounter >= RESEND_INTERVAL_TICKS) { + tickCounter = 0; + send(); + } + }); + } + + private static void send() { + // ClientPlayNetworking.send no-ops when the server hasn't registered the payload + // type (e.g. vanilla server or server without this mod) — safe to call blindly. + if (ClientPlayNetworking.canSend(MqHelloPayload.TYPE)) { + ClientPlayNetworking.send(new MqHelloPayload(MqHelloPayload.CURRENT_VERSION)); + } + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/MqHelloPayload.java b/src/main/java/com/ejclaw/videoplayer/net/MqHelloPayload.java new file mode 100644 index 0000000..eed0652 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/net/MqHelloPayload.java @@ -0,0 +1,35 @@ +package com.ejclaw.videoplayer.net; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * C2S — client-presence handshake for the {@code music_quiz} datapack + * ({@code docs/mc_video_player_mod_integration.md}). Each modded client sends one on JOIN + * and one every ~5 s thereafter. The server side flips the player's {@code mq_video_mod} + * scoreboard score to {@code 1}, which the datapack reads in its {@code mq:commands/start} + * guard. + * + *

Payload body is a single int (protocol version) so future schema bumps don't require + * a new packet id. v1 is the only version that exists today. + */ +public record MqHelloPayload(int version) implements CustomPacketPayload { + public static final int CURRENT_VERSION = 1; + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "mq_hello")); + + public static final StreamCodec CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_INT, MqHelloPayload::version, + MqHelloPayload::new + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java index bb4e7e5..fa1a363 100644 --- a/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java +++ b/src/main/java/com/ejclaw/videoplayer/net/VideoPlayerNetwork.java @@ -32,6 +32,7 @@ public final class VideoPlayerNetwork { // C2S PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(MqHelloPayload.TYPE, MqHelloPayload.CODEC); } public static void registerServerReceivers() { diff --git a/src/main/java/com/ejclaw/videoplayer/server/MusicQuizPresence.java b/src/main/java/com/ejclaw/videoplayer/server/MusicQuizPresence.java new file mode 100644 index 0000000..dc34a32 --- /dev/null +++ b/src/main/java/com/ejclaw/videoplayer/server/MusicQuizPresence.java @@ -0,0 +1,74 @@ +package com.ejclaw.videoplayer.server; + +import com.ejclaw.videoplayer.VideoPlayerMod; +import com.ejclaw.videoplayer.net.MqHelloPayload; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.ScoreHolder; +import net.minecraft.world.scores.Scoreboard; + +/** + * Implements the server side of the {@code music_quiz} datapack handshake described in + * {@code docs/mc_video_player_mod_integration.md}. + * + *

Two scoreboard writes against objective {@code mq_video_mod} (dummy): + *

+ * + *

The objective itself ({@code scoreboard objectives add mq_video_mod dummy}) is + * created by the datapack's {@code mq:load} function. If it isn't present yet (datapack + * not applied, or load hasn't run), the writes are silently skipped — when the datapack + * appears later, the next tick / next payload arrival populates it. + */ +public final class MusicQuizPresence { + private MusicQuizPresence() {} + + /** Scoreboard objective name expected by the {@code music_quiz} datapack. */ + private static final String OBJECTIVE = "mq_video_mod"; + /** Fake holder used to mark "the server has the mod" (datapack reads {@code #server}). */ + private static final String SERVER_HOLDER = "#server"; + + /** Call from {@code VideoPlayerMod.onInitialize} after payload types are registered. */ + public static void register() { + // (a) per-tick server presence + ServerTickEvents.END_SERVER_TICK.register(MusicQuizPresence::onServerTick); + + // (b) per-player client presence — flipped on every received Hello + ServerPlayNetworking.registerGlobalReceiver(MqHelloPayload.TYPE, (payload, context) -> { + ServerPlayer player = context.player(); + MinecraftServer server = context.server(); + // hop back onto the server thread before touching the scoreboard + server.execute(() -> markPlayerPresent(server, player)); + }); + } + + private static void onServerTick(MinecraftServer server) { + Scoreboard sb = server.getScoreboard(); + Objective obj = sb.getObjective(OBJECTIVE); + // Datapack not loaded yet — silently skip. The score holder doesn't exist until + // the objective does, and trying to write blind would just blow up. + if (obj == null) return; + sb.getOrCreatePlayerScore(ScoreHolder.forNameOnly(SERVER_HOLDER), obj).set(1); + } + + private static void markPlayerPresent(MinecraftServer server, ServerPlayer player) { + Scoreboard sb = server.getScoreboard(); + Objective obj = sb.getObjective(OBJECTIVE); + if (obj == null) { + VideoPlayerMod.LOG.debug("[{}] mq hello from {} but objective '{}' not present", + VideoPlayerMod.MOD_ID, player.getName().getString(), OBJECTIVE); + return; + } + // ServerPlayer itself implements ScoreHolder, so this matches selector @s on the + // datapack side without name-formatting quirks (uuid vs profile name). + sb.getOrCreatePlayerScore(player, obj).set(1); + } +}