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 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);
+ }
+}
+ *
+ *
+ *