diff --git a/README.md b/README.md index 5b1bd37..74c0935 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sanctuary 26.2 -Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25. +Workspace Fabric multi-projet de Sanctuary 2.0. Il construit les dix JAR du pack et le mod événementiel ZEVENT distribué à part, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25. Auteur : **KOKA99CAB** Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`) @@ -19,6 +19,7 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`) | `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant | | `events` | Events | événements serveur | socle vide | | `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal | +| `zevent` | ZEVENT | diffusion Twitch temporaire | écran mural 9×5, hors pack principal | Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module. @@ -418,7 +419,7 @@ Le menu pause est lui aussi possédé par Sanctuary. Il ne conserve que Retour a ## Versions du modpack et mises à jour Modrinth -`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `_version` et `_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. +`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `_version` et `_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement. La version globale du pack n’est pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module. diff --git a/gradle.properties b/gradle.properties index 4c5437d..4fa9d84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,5 +30,7 @@ onlyfun_version=0.0.0-alpha.27 onlyfun_lifecycle=active sanctuary_version=0.0.0-alpha.139 sanctuary_lifecycle=active +zevent_version=0.0.0-alpha.1 +zevent_lifecycle=active pack_version=26.2.0-alpha.233 maven_group=fr.koka99cab.sanctuary26 diff --git a/settings.gradle b/settings.gradle index f7665ef..3628eb6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,5 +21,6 @@ include( "onlyfun", "sanctuary", "events", - "masterkey" + "masterkey", + "zevent" ) diff --git a/zevent/README.md b/zevent/README.md new file mode 100644 index 0000000..92d6076 --- /dev/null +++ b/zevent/README.md @@ -0,0 +1,19 @@ +# ZEVENT + +Mod Fabric événementiel distribué séparément du pack Sanctuary. Il ajoute un écran mural multibloc de 9×5 blocs qui lit une chaîne Twitch en direct. + +## Utilisation + +1. Poser l'écran contre un mur libre de 9×5 blocs. Le bloc visé devient le centre de l'écran. +2. Faire un clic droit sur n'importe laquelle de ses 45 cases. +3. Saisir un nom de chaîne (`zeventfr`) ou coller une URL Twitch, puis valider. + +La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client rend le lecteur Twitch officiel dans la surface. Son volume utilise la catégorie **Disques** de Minecraft et diminue jusqu'au silence à 64 blocs. Seul l'écran le plus proche est audible et deux flux visibles au maximum sont décodés par client. + +## Dépendance et limites + +Le client exige [MCEF Modern](https://modrinth.com/mod/mcef-modern) `0.3.3+mc26.2.jcef146.0.10`. MCEF télécharge son runtime Chromium natif lors de la première utilisation ; une connexion Internet est donc nécessaire. Le lecteur utilise le backend OpenGL pris en charge par cette version de MCEF. + +Le serveur ne reçoit jamais d'URL arbitraire : les entrées sont ramenées à un identifiant Twitch de 1 à 25 caractères (`a-z`, `0-9`, `_`). La page locale est liée uniquement à l'interface loopback et n'expose que le lecteur officiel `player.twitch.tv`. + +Documentation du lecteur : [Twitch Embedded Video & Clips](https://dev.twitch.tv/docs/embed/video-and-clips/). diff --git a/zevent/art/icon.svg b/zevent/art/icon.svg new file mode 100644 index 0000000..f0000f9 --- /dev/null +++ b/zevent/art/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/zevent/build.gradle b/zevent/build.gradle new file mode 100644 index 0000000..389f73b --- /dev/null +++ b/zevent/build.gradle @@ -0,0 +1,14 @@ +repositories { + mavenCentral() +} + +dependencies { + implementation "maven.modrinth:mcef-modern:FOXbUPNd" + compileOnly "me.friwi:jcef-api:jcef-d3de827+cef-146.0.10+g8219561+chromium-146.0.7680.179" + testImplementation "org.junit.jupiter:junit-jupiter:5.11.4" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.11.4" +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java new file mode 100644 index 0000000..b5bc4e5 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java @@ -0,0 +1,30 @@ +package fr.koka99cab.sanctuary26.zevent; + +import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking; +import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries; +import net.fabricmc.api.ModInitializer; +import net.minecraft.resources.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ZeventMod implements ModInitializer { + public static final String MOD_ID = "zevent"; + public static final String DISPLAY_NAME = "ZEVENT"; + public static final int CURRENT_DATA_VERSION = 1; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + public ZeventMod() { + } + + public static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(MOD_ID, path); + } + + @Override + public void onInitialize() { + ZeventRegistries.initialize(); + ZeventNetworking.initialize(); + LOGGER.info("[{}] 9x5 Twitch screen ready (data v{}, network v{}).", + DISPLAY_NAME, CURRENT_DATA_VERSION, ZeventNetworking.PROTOCOL_VERSION); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java new file mode 100644 index 0000000..45a504e --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java @@ -0,0 +1,206 @@ +package fr.koka99cab.sanctuary26.zevent.block; + +import com.mojang.serialization.MapCodec; +import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity; +import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking; +import java.util.Map; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.RandomSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.ScheduledTickAccess; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** One block type represents all 45 cells; only its central cell owns playback state. */ +public final class ZeventScreenBlock extends BaseEntityBlock { + public static final MapCodec CODEC = simpleCodec(ZeventScreenBlock::new); + public static final EnumProperty FACING = BlockStateProperties.HORIZONTAL_FACING; + public static final IntegerProperty COLUMN = IntegerProperty.create("column", 0, ZeventScreenLayout.WIDTH - 1); + public static final IntegerProperty ROW = IntegerProperty.create("row", 0, ZeventScreenLayout.HEIGHT - 1); + private static final ThreadLocal REMOVING_STRUCTURE = ThreadLocal.withInitial(() -> false); + private static final Map SHAPES = Map.of( + Direction.NORTH, Block.box(0.0D, 0.0D, 14.0D, 16.0D, 16.0D, 16.0D), + Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 2.0D), + Direction.WEST, Block.box(14.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D), + Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 2.0D, 16.0D, 16.0D) + ); + + public ZeventScreenBlock(BlockBehaviour.Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any() + .setValue(FACING, Direction.NORTH) + .setValue(COLUMN, ZeventScreenLayout.CONTROLLER_COLUMN) + .setValue(ROW, ZeventScreenLayout.CONTROLLER_ROW)); + } + + @Override + public MapCodec codec() { + return CODEC; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new ZeventScreenBlockEntity(pos, state); + } + + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction facing = context.getClickedFace(); + if (!facing.getAxis().isHorizontal()) return null; + BlockPos controller = context.getClickedPos(); + if (!canPlaceScreen(context.getLevel(), controller, facing)) return null; + return defaultBlockState().setValue(FACING, facing); + } + + @Override + public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) { + super.setPlacedBy(level, pos, state, placer, stack); + if (level.isClientSide() || !ZeventScreenLayout.isController(state)) return; + Direction facing = state.getValue(FACING); + for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) { + for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) { + if (column == ZeventScreenLayout.CONTROLLER_COLUMN + && row == ZeventScreenLayout.CONTROLLER_ROW) continue; + BlockPos part = ZeventScreenLayout.partPos(pos, facing, column, row); + level.setBlock(part, defaultBlockState().setValue(FACING, facing) + .setValue(COLUMN, column).setValue(ROW, row), Block.UPDATE_ALL); + } + } + } + + @Override + protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) { + Direction facing = state.getValue(FACING); + BlockPos support = pos.relative(facing.getOpposite()); + return level.getBlockState(support).isFaceSturdy(level, support, facing); + } + + @Override + protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks, + BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) { + return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos) + ? Blocks.AIR.defaultBlockState() + : super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPES.get(state.getValue(FACING)); + } + + @Override + protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, + Player player, InteractionHand hand, BlockHitResult hit) { + return openOptions(state, level, pos, player); + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + return openOptions(state, level, pos, player); + } + + @Override + public BlockState playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) { + if (!level.isClientSide()) removeOtherParts(level, pos, state); + return super.playerWillDestroy(level, pos, state, player); + } + + @Override + protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos, + boolean movedByPiston) { + if (!REMOVING_STRUCTURE.get()) removeOtherParts(level, pos, state); + super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING, COLUMN, ROW); + } + + @Override + protected BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue(FACING, rotation.rotate(state.getValue(FACING))); + } + + @Override + protected BlockState mirror(BlockState state, Mirror mirror) { + return state.rotate(mirror.getRotation(state.getValue(FACING))); + } + + private static boolean canPlaceScreen(LevelReader level, BlockPos controller, Direction facing) { + for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) { + for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) { + BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row); + if (level.isOutsideBuildHeight(part) || !level.getBlockState(part).isAir()) return false; + BlockPos support = part.relative(facing.getOpposite()); + if (!level.getBlockState(support).isFaceSturdy(level, support, facing)) return false; + } + } + return true; + } + + private static InteractionResult openOptions(BlockState state, Level level, BlockPos pos, Player player) { + if (level.isClientSide()) return InteractionResult.SUCCESS; + BlockPos controller = ZeventScreenLayout.controllerPos(pos, state); + if (player instanceof ServerPlayer serverPlayer + && level.getBlockEntity(controller) instanceof ZeventScreenBlockEntity screen) { + ZeventNetworking.openOptions(serverPlayer, screen); + return InteractionResult.SUCCESS_SERVER; + } + return InteractionResult.CONSUME; + } + + private static void removeOtherParts(Level level, BlockPos brokenPos, BlockState brokenState) { + if (!(brokenState.getBlock() instanceof ZeventScreenBlock)) return; + BlockPos controller = ZeventScreenLayout.controllerPos(brokenPos, brokenState); + Direction facing = brokenState.getValue(FACING); + REMOVING_STRUCTURE.set(true); + try { + for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) { + for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) { + BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row); + if (part.equals(brokenPos)) continue; + BlockState candidate = level.getBlockState(part); + if (candidate.getBlock() instanceof ZeventScreenBlock + && ZeventScreenLayout.controllerPos(part, candidate).equals(controller)) { + level.setBlock(part, Blocks.AIR.defaultBlockState(), + Block.UPDATE_ALL | Block.UPDATE_SUPPRESS_DROPS); + } + } + } + } finally { + REMOVING_STRUCTURE.set(false); + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java new file mode 100644 index 0000000..2d2db38 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java @@ -0,0 +1,54 @@ +package fr.koka99cab.sanctuary26.zevent.block; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; + +/** Pure 9x5 layout math shared by placement, interaction and teardown. */ +public final class ZeventScreenLayout { + public static final int WIDTH = 9; + public static final int HEIGHT = 5; + public static final int CONTROLLER_COLUMN = WIDTH / 2; + public static final int CONTROLLER_ROW = HEIGHT / 2; + + private ZeventScreenLayout() { + } + + public static Direction right(Direction facing) { + if (!facing.getAxis().isHorizontal()) throw new IllegalArgumentException("Horizontal facing required"); + return facing.getCounterClockWise(); + } + + public static BlockPos partPos(BlockPos controller, Direction facing, int column, int row) { + validateCell(column, row); + Direction right = right(facing); + return controller.offset( + right.getStepX() * (column - CONTROLLER_COLUMN), + row - CONTROLLER_ROW, + right.getStepZ() * (column - CONTROLLER_COLUMN) + ); + } + + public static BlockPos controllerPos(BlockPos part, BlockState state) { + Direction facing = state.getValue(ZeventScreenBlock.FACING); + int column = state.getValue(ZeventScreenBlock.COLUMN); + int row = state.getValue(ZeventScreenBlock.ROW); + Direction right = right(facing); + return part.offset( + -right.getStepX() * (column - CONTROLLER_COLUMN), + -(row - CONTROLLER_ROW), + -right.getStepZ() * (column - CONTROLLER_COLUMN) + ); + } + + public static boolean isController(BlockState state) { + return state.getValue(ZeventScreenBlock.COLUMN) == CONTROLLER_COLUMN + && state.getValue(ZeventScreenBlock.ROW) == CONTROLLER_ROW; + } + + private static void validateCell(int column, int row) { + if (column < 0 || column >= WIDTH || row < 0 || row >= HEIGHT) { + throw new IllegalArgumentException("Invalid ZEVENT screen cell " + column + "," + row); + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java new file mode 100644 index 0000000..fd259ca --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java @@ -0,0 +1,75 @@ +package fr.koka99cab.sanctuary26.zevent.block.entity; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** The central cell persists one bounded Twitch channel; all other cells are stateless mirrors. */ +public final class ZeventScreenBlockEntity extends BlockEntity { + private static final String DATA_VERSION_KEY = "DataVersion"; + private static final String CHANNEL_KEY = "Channel"; + private int dataVersion = ZeventMod.CURRENT_DATA_VERSION; + private String channel = TwitchChannel.DEFAULT; + + public ZeventScreenBlockEntity(BlockPos pos, BlockState state) { + super(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY, pos, state); + } + + public String channel() { + return channel; + } + + public boolean setChannel(String requested) { + if (dataVersion > ZeventMod.CURRENT_DATA_VERSION) return false; + String normalized = TwitchChannel.normalize(requested).orElse(null); + if (normalized == null) return false; + if (normalized.equals(channel)) return true; + channel = normalized; + setChanged(); + if (level != null) { + BlockState state = getBlockState(); + level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS); + } + return true; + } + + public boolean writable() { + return dataVersion <= ZeventMod.CURRENT_DATA_VERSION; + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + output.putInt(DATA_VERSION_KEY, dataVersion); + output.putString(CHANNEL_KEY, channel); + } + + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + dataVersion = input.getIntOr(DATA_VERSION_KEY, 1); + channel = TwitchChannel.normalize(input.getStringOr(CHANNEL_KEY, TwitchChannel.DEFAULT)) + .orElse(TwitchChannel.DEFAULT); + } + + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider lookup) { + return saveWithoutMetadata(lookup); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java new file mode 100644 index 0000000..c7f5da6 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java @@ -0,0 +1,29 @@ +package fr.koka99cab.sanctuary26.zevent.client; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.client.gui.ZeventScreenOptionsScreen; +import fr.koka99cab.sanctuary26.zevent.client.render.ZeventScreenBlockEntityRenderer; +import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager; +import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload; +import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +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; +import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry; + +public final class ZeventClient implements ClientModInitializer { + @Override + public void onInitializeClient() { + BlockEntityRendererRegistry.register(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY, + ZeventScreenBlockEntityRenderer::new); + ClientPlayNetworking.registerGlobalReceiver(OpenScreenOptionsPayload.TYPE, (payload, context) -> + context.client().execute(() -> context.client().gui.setScreen( + new ZeventScreenOptionsScreen(context.client().gui.screen(), payload)))); + ClientTickEvents.END_CLIENT_TICK.register(TwitchBrowserManager::tick); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TwitchBrowserManager.clear(client)); + ClientLifecycleEvents.CLIENT_STOPPING.register(TwitchBrowserManager::shutdown); + ZeventMod.LOGGER.info("[{}] Twitch browser renderer registered.", ZeventMod.DISPLAY_NAME); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java new file mode 100644 index 0000000..c4c44ca --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java @@ -0,0 +1,117 @@ +package fr.koka99cab.sanctuary26.zevent.client.gui; + +import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload; +import fr.koka99cab.sanctuary26.zevent.network.SaveScreenChannelPayload; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** Small in-game configuration surface opened by right-clicking any screen cell. */ +public final class ZeventScreenOptionsScreen extends Screen { + private static final int PANEL_WIDTH = 340; + private static final int PANEL_HEIGHT = 184; + private static final int BACKDROP = 0xE0121715; + private static final int BORDER = 0xFF3BEA8C; + private static final int TITLE = 0xFFF5FFF9; + private static final int TEXT = 0xFFC7D7CE; + private static final int ERROR = 0xFFFF6B78; + private final Screen parent; + private final OpenScreenOptionsPayload payload; + private EditBox channel; + private Button save; + private Component validation = Component.empty(); + + public ZeventScreenOptionsScreen(Screen parent, OpenScreenOptionsPayload payload) { + super(Component.translatable("screen.zevent.options.title")); + this.parent = parent; + this.payload = payload; + } + + @Override + protected void init() { + int x = panelX() + 16; + int y = panelY(); + channel = addRenderableWidget(new EditBox(font, x, y + 54, PANEL_WIDTH - 32, 20, + Component.translatable("screen.zevent.options.channel"))); + channel.setMaxLength(TwitchChannel.MAX_INPUT_LENGTH); + channel.setValue(payload.channel()); + channel.setHint(Component.translatable("screen.zevent.options.hint")); + channel.setEditable(payload.writable()); + channel.setResponder(value -> refreshValidation()); + + Button official = addRenderableWidget(Button.builder( + Component.translatable("screen.zevent.options.official"), button -> select("zeventfr")) + .bounds(x, y + 82, 150, 20).build()); + Button zerator = addRenderableWidget(Button.builder( + Component.translatable("screen.zevent.options.zerator"), button -> select("zerator")) + .bounds(x + 158, y + 82, 150, 20).build()); + official.active = payload.writable(); + zerator.active = payload.writable(); + + save = addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> submit()) + .bounds(x, y + 142, 150, 20).build()); + addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose()) + .bounds(x + 158, y + 142, 150, 20).build()); + refreshValidation(); + setInitialFocus(channel); + } + + @Override + public void tick() { + refreshValidation(); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) { + int x = panelX(); + int y = panelY(); + graphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, BACKDROP); + graphics.outline(x, y, PANEL_WIDTH, PANEL_HEIGHT, BORDER); + graphics.centeredText(font, title, width / 2, y + 12, TITLE); + graphics.text(font, Component.translatable("screen.zevent.options.current", payload.channel()), + x + 16, y + 31, TEXT); + graphics.text(font, Component.translatable("screen.zevent.options.channel"), x + 16, y + 43, TEXT); + if (!validation.getString().isEmpty()) graphics.text(font, validation, x + 16, y + 113, ERROR); + super.extractRenderState(graphics, mouseX, mouseY, tickDelta); + } + + private void select(String selected) { + if (payload.writable()) channel.setValue(selected); + } + + private void refreshValidation() { + if (channel == null || save == null) return; + boolean valid = TwitchChannel.normalize(channel.getValue()).isPresent(); + validation = valid ? Component.empty() : Component.translatable("screen.zevent.options.invalid"); + save.active = payload.writable() && valid && ClientPlayNetworking.canSend(SaveScreenChannelPayload.TYPE); + } + + private void submit() { + String normalized = TwitchChannel.normalize(channel.getValue()).orElse(null); + if (normalized == null || !save.active) return; + ClientPlayNetworking.send(new SaveScreenChannelPayload(payload.pos(), normalized)); + onClose(); + } + + private int panelX() { + return (width - PANEL_WIDTH) / 2; + } + + private int panelY() { + return Math.max(2, (height - PANEL_HEIGHT) / 2); + } + + @Override + public void onClose() { + if (minecraft != null) minecraft.gui.setScreen(parent); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java new file mode 100644 index 0000000..91beb4b --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java @@ -0,0 +1,95 @@ +package fr.koka99cab.sanctuary26.zevent.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock; +import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout; +import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity; +import fr.koka99cab.sanctuary26.zevent.client.render.state.ZeventScreenRenderState; +import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.Direction; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.world.phys.Vec3; + +/** Projects one browser texture over the central block entity's complete 9x5 surface. */ +public final class ZeventScreenBlockEntityRenderer + implements BlockEntityRenderer { + private static final int VIEW_DISTANCE = 128; + private static final double VIEW_DISTANCE_SQR = VIEW_DISTANCE * VIEW_DISTANCE; + private static final float HALF_WIDTH = 4.42F; + private static final float HALF_HEIGHT = 2.42F; + private static final float CENTER_TO_FRONT = 0.369F; + + public ZeventScreenBlockEntityRenderer(BlockEntityRendererProvider.Context context) { + } + + @Override + public ZeventScreenRenderState createRenderState() { + return new ZeventScreenRenderState(); + } + + @Override + public void extractRenderState(ZeventScreenBlockEntity screen, ZeventScreenRenderState state, + float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) { + BlockEntityRenderer.super.extractRenderState(screen, state, tickProgress, cameraPos, crumblingOverlay); + state.texture = null; + if (!ZeventScreenLayout.isController(screen.getBlockState()) || screen.getLevel() == null) return; + state.facing = screen.getBlockState().getValue(ZeventScreenBlock.FACING); + state.texture = TwitchBrowserManager.markVisible(screen.getLevel(), screen.getBlockPos(), screen.channel()); + } + + @Override + public void submit(ZeventScreenRenderState state, PoseStack poseStack, + SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) { + if (state.texture == null) return; + submitNodeCollector.submitCustomGeometry(poseStack, + RenderTypes.entityTranslucentEmissive(state.texture), + (pose, consumer) -> drawScreen(pose, consumer, state.facing)); + } + + @Override + public boolean shouldRenderOffScreen() { + return true; + } + + @Override + public int getViewDistance() { + return VIEW_DISTANCE; + } + + @Override + public boolean shouldRender(ZeventScreenBlockEntity screen, Vec3 cameraPos) { + return ZeventScreenLayout.isController(screen.getBlockState()) + && Vec3.atCenterOf(screen.getBlockPos()).distanceToSqr(cameraPos) <= VIEW_DISTANCE_SQR; + } + + private static void drawScreen(PoseStack.Pose pose, VertexConsumer consumer, Direction facing) { + Direction right = ZeventScreenLayout.right(facing); + float centerX = 0.5F - facing.getStepX() * CENTER_TO_FRONT; + float centerZ = 0.5F - facing.getStepZ() * CENTER_TO_FRONT; + vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, -HALF_HEIGHT, 0.0F, 1.0F); + vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, -HALF_HEIGHT, 1.0F, 1.0F); + vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, HALF_HEIGHT, 1.0F, 0.0F); + vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, HALF_HEIGHT, 0.0F, 0.0F); + } + + private static void vertex(VertexConsumer consumer, PoseStack.Pose pose, float centerX, float centerZ, + Direction right, Direction normal, float horizontal, float vertical, float u, float v) { + consumer.addVertex(pose, + centerX + right.getStepX() * horizontal, + 0.5F + vertical, + centerZ + right.getStepZ() * horizontal) + .setColor(0xFFFFFFFF) + .setUv(u, v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(LightCoordsUtil.FULL_BRIGHT) + .setNormal(pose, normal.getStepX(), 0.0F, normal.getStepZ()); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java new file mode 100644 index 0000000..24d26d8 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java @@ -0,0 +1,10 @@ +package fr.koka99cab.sanctuary26.zevent.client.render.state; + +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; + +public final class ZeventScreenRenderState extends BlockEntityRenderState { + public Direction facing = Direction.NORTH; + public Identifier texture; +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java new file mode 100644 index 0000000..f65fa5f --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java @@ -0,0 +1,34 @@ +package fr.koka99cab.sanctuary26.zevent.client.twitch; + +import com.mojang.blaze3d.textures.GpuTexture; +import com.mojang.blaze3d.textures.GpuTextureView; +import net.dimaskama.mcef.api.MCEFBrowser; +import net.minecraft.client.renderer.texture.AbstractTexture; + +/** Texture-manager bridge; MCEF remains the sole owner of the GPU allocation. */ +final class BrowserTexture extends AbstractTexture { + private final MCEFBrowser browser; + + BrowserTexture(MCEFBrowser browser) { + this.browser = browser; + } + + @Override + public GpuTexture getTexture() { + GpuTexture current = browser.getTexture(); + if (current == null) throw new IllegalStateException("MCEF browser texture is not ready"); + return current; + } + + @Override + public GpuTextureView getTextureView() { + GpuTextureView current = browser.getTextureView(); + if (current == null) throw new IllegalStateException("MCEF browser texture view is not ready"); + return current; + } + + @Override + public void close() { + // The browser closes its changing texture and view; the texture manager only drops this bridge. + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java new file mode 100644 index 0000000..ba01911 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java @@ -0,0 +1,206 @@ +package fr.koka99cab.sanctuary26.zevent.client.twitch; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout; +import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import java.io.IOException; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import net.dimaskama.mcef.api.MCEFApi; +import net.dimaskama.mcef.api.MCEFBrowser; +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** Owns a small, distance-bounded set of official Twitch browser players. */ +public final class TwitchBrowserManager { + private static final int BROWSER_WIDTH = 900; + private static final int BROWSER_HEIGHT = 500; + private static final int MAXIMUM_BROWSERS = 2; + private static final int STALE_TICKS = 40; + private static final int AUDIBLE_FRESH_TICKS = 10; + private static final double AUDIO_DISTANCE = 64.0D; + private static final double AUDIO_DISTANCE_SQR = AUDIO_DISTANCE * AUDIO_DISTANCE; + private static final double FULL_VOLUME_DISTANCE = 4.0D; + private static final TwitchPlayerPageServer PAGE_SERVER = new TwitchPlayerPageServer(); + private static final Map SESSIONS = new LinkedHashMap<>(); + private static boolean initializationNoticeShown; + + private TwitchBrowserManager() { + } + + public static Identifier markVisible(Level level, BlockPos pos, String requestedChannel) { + Minecraft client = Minecraft.getInstance(); + if (client.level != level || client.player == null) return null; + String channel = TwitchChannel.normalize(requestedChannel).orElse(TwitchChannel.DEFAULT); + ScreenKey key = new ScreenKey(level.dimension(), pos.immutable()); + Session session = SESSIONS.get(key); + if (session == null) { + if (!admit(client, pos)) return null; + session = new Session(key, channel, textureId(key)); + SESSIONS.put(key, session); + } + session.lastSeenTick = level.getGameTime(); + if (!session.channel.equals(channel)) session.changeChannel(channel); + if (session.browser == null && !session.starting && !session.failed) start(client, session); + return session.browser != null && session.browser.getTextureView() != null ? session.textureId : null; + } + + public static void tick(Minecraft client) { + if (client.level == null || client.player == null) { + clear(client); + return; + } + long now = client.level.getGameTime(); + for (Session session : SESSIONS.values().toArray(Session[]::new)) { + boolean wrongLevel = !session.key.dimension.equals(client.level.dimension()); + boolean stale = now < session.lastSeenTick || now - session.lastSeenTick > STALE_TICKS; + boolean missing = !client.level.hasChunkAt(session.key.pos) + || !(client.level.getBlockEntity(session.key.pos) instanceof ZeventScreenBlockEntity screen) + || !ZeventScreenLayout.isController(screen.getBlockState()); + if (wrongLevel || stale || missing) remove(client, session); + } + if (now % 5L == 0L) updateVolumes(client, now); + } + + public static void clear(Minecraft client) { + for (Session session : SESSIONS.values().toArray(Session[]::new)) remove(client, session); + SESSIONS.clear(); + initializationNoticeShown = false; + } + + public static void shutdown(Minecraft client) { + clear(client); + PAGE_SERVER.stop(); + } + + private static boolean admit(Minecraft client, BlockPos requested) { + if (SESSIONS.size() < MAXIMUM_BROWSERS) return true; + Session farthest = SESSIONS.values().stream().max(Comparator.comparingDouble( + session -> distanceSqr(client, session.key.pos))).orElse(null); + if (farthest == null || distanceSqr(client, requested) >= distanceSqr(client, farthest.key.pos)) return false; + remove(client, farthest); + return true; + } + + private static void start(Minecraft client, Session session) { + session.starting = true; + if (!initializationNoticeShown && client.player != null) { + initializationNoticeShown = true; + client.player.sendOverlayMessage(Component.translatable("message.zevent.player.initializing")); + } + MCEFApi.getInstanceFuture().whenComplete((api, error) -> client.execute(() -> { + if (SESSIONS.get(session.key) != session) return; + session.starting = false; + if (error != null) { + session.failed = true; + ZeventMod.LOGGER.error("Unable to initialize MCEF for the ZEVENT screen", error); + return; + } + try { + MCEFBrowser browser = api.createBrowser(PAGE_SERVER.playerUrl(session.channel), false); + browser.resize(BROWSER_WIDTH, BROWSER_HEIGHT); + browser.setFocus(false); + session.browser = browser; + client.getTextureManager().register(session.textureId, new BrowserTexture(browser)); + session.textureRegistered = true; + } catch (IOException | RuntimeException | LinkageError exception) { + session.failed = true; + ZeventMod.LOGGER.error("Unable to create the ZEVENT Twitch player", exception); + } + })); + } + + private static void updateVolumes(Minecraft client, long now) { + Session nearest = SESSIONS.values().stream() + .filter(session -> session.browser != null && now - session.lastSeenTick <= AUDIBLE_FRESH_TICKS) + .filter(session -> distanceSqr(client, session.key.pos) <= AUDIO_DISTANCE_SQR) + .min(Comparator.comparingDouble(session -> distanceSqr(client, session.key.pos))) + .orElse(null); + for (Session session : SESSIONS.values()) { + float volume = session == nearest ? attenuatedVolume(client, session.key.pos) : 0.0F; + session.updateVolume(volume); + } + } + + private static float attenuatedVolume(Minecraft client, BlockPos pos) { + double distance = Math.sqrt(distanceSqr(client, pos)); + double attenuation = distance <= FULL_VOLUME_DISTANCE ? 1.0D + : 1.0D - (distance - FULL_VOLUME_DISTANCE) / (AUDIO_DISTANCE - FULL_VOLUME_DISTANCE); + float records = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); + return Math.clamp((float) attenuation, 0.0F, 1.0F) * records; + } + + private static double distanceSqr(Minecraft client, BlockPos pos) { + return client.player == null ? Double.POSITIVE_INFINITY + : client.player.distanceToSqr(Vec3.atCenterOf(pos)); + } + + private static void remove(Minecraft client, Session session) { + if (SESSIONS.remove(session.key) == null) return; + if (session.browser != null) session.updateVolume(0.0F); + if (session.textureRegistered) client.getTextureManager().release(session.textureId); + if (session.browser != null) { + try { + session.browser.close(); + } catch (RuntimeException | LinkageError exception) { + ZeventMod.LOGGER.warn("Unable to close a ZEVENT browser cleanly", exception); + } + session.browser = null; + } + } + + private static Identifier textureId(ScreenKey key) { + String dimension = Integer.toUnsignedString(key.dimension.identifier().hashCode(), 36); + return ZeventMod.id("live/" + dimension + "/" + Long.toUnsignedString(key.pos.asLong(), 36)); + } + + private record ScreenKey(ResourceKey dimension, BlockPos pos) { + } + + private static final class Session { + private final ScreenKey key; + private final Identifier textureId; + private String channel; + private MCEFBrowser browser; + private long lastSeenTick; + private float lastVolume = -1.0F; + private boolean starting; + private boolean failed; + private boolean textureRegistered; + + private Session(ScreenKey key, String channel, Identifier textureId) { + this.key = key; + this.channel = channel; + this.textureId = textureId; + } + + private void changeChannel(String next) { + channel = next; + if (browser == null) return; + execute("window.zeventSetChannel&&window.zeventSetChannel('" + next + "');"); + } + + private void updateVolume(float volume) { + if (browser == null || Math.abs(volume - lastVolume) < 0.01F) return; + lastVolume = volume; + execute("window.zeventSetVolume&&window.zeventSetVolume(" + Float.toString(volume) + ");"); + } + + private void execute(String script) { + try { + String url = browser.getCefBrowser().getURL(); + browser.getCefBrowser().executeJavaScript(script, url == null ? "" : url, 0); + } catch (RuntimeException | LinkageError exception) { + ZeventMod.LOGGER.debug("ZEVENT browser command failed: {}", exception.getMessage()); + } + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java new file mode 100644 index 0000000..11f0466 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java @@ -0,0 +1,108 @@ +package fr.koka99cab.sanctuary26.zevent.client.twitch; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Loopback-only origin required by Twitch's official interactive embed. */ +final class TwitchPlayerPageServer { + private static final String PLAYER_PATH = "/player/"; + private HttpServer server; + private ExecutorService executor; + + synchronized String playerUrl(String channel) throws IOException { + if (!TwitchChannel.valid(channel)) throw new IllegalArgumentException("Invalid Twitch channel"); + ensureStarted(); + return "http://localhost:" + server.getAddress().getPort() + PLAYER_PATH + channel; + } + + synchronized void stop() { + if (server != null) { + server.stop(0); + server = null; + } + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + private void ensureStarted() throws IOException { + if (server != null) return; + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "ZEVENT Twitch page"); + thread.setDaemon(true); + return thread; + }); + server.setExecutor(executor); + server.createContext(PLAYER_PATH, this::servePlayer); + server.start(); + } + + private void servePlayer(HttpExchange exchange) throws IOException { + try (exchange) { + if (!"GET".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1L); + return; + } + String path = exchange.getRequestURI().getPath(); + String channel = path.startsWith(PLAYER_PATH) + ? TwitchChannel.normalize(path.substring(PLAYER_PATH.length())).orElse(null) + : null; + if (channel == null) { + exchange.sendResponseHeaders(404, -1L); + return; + } + byte[] body = page(channel).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer"); + exchange.getResponseHeaders().set("Content-Security-Policy", + "default-src 'self' https: wss: blob: data:; script-src 'unsafe-inline' https://player.twitch.tv; " + + "frame-src https://player.twitch.tv; style-src 'unsafe-inline'"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + } + + private static String page(String channel) { + return """ + + + + + + + + +
+ + """.replace("__CHANNEL__", channel); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java new file mode 100644 index 0000000..4ada7e8 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java @@ -0,0 +1,37 @@ +package fr.koka99cab.sanctuary26.zevent.network; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +public record OpenScreenOptionsPayload(BlockPos pos, String channel, boolean writable) + implements CustomPacketPayload { + public static final Type TYPE = + new Type<>(ZeventMod.id("s2c/open_screen_options")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.of(OpenScreenOptionsPayload::write, OpenScreenOptionsPayload::read); + + public OpenScreenOptionsPayload { + pos = pos == null ? BlockPos.ZERO : pos.immutable(); + channel = TwitchChannel.normalize(channel).orElse(TwitchChannel.DEFAULT); + } + + private static OpenScreenOptionsPayload read(RegistryFriendlyByteBuf buffer) { + return new OpenScreenOptionsPayload(buffer.readBlockPos(), + buffer.readUtf(TwitchChannel.MAX_NAME_LENGTH), buffer.readBoolean()); + } + + private static void write(RegistryFriendlyByteBuf buffer, OpenScreenOptionsPayload payload) { + buffer.writeBlockPos(payload.pos); + buffer.writeUtf(payload.channel, TwitchChannel.MAX_NAME_LENGTH); + buffer.writeBoolean(payload.writable); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java new file mode 100644 index 0000000..09f2a29 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java @@ -0,0 +1,36 @@ +package fr.koka99cab.sanctuary26.zevent.network; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +public record SaveScreenChannelPayload(BlockPos pos, String channel) implements CustomPacketPayload { + public static final Type TYPE = + new Type<>(ZeventMod.id("c2s/save_screen_channel")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.of(SaveScreenChannelPayload::write, SaveScreenChannelPayload::read); + + public SaveScreenChannelPayload { + pos = pos == null ? BlockPos.ZERO : pos.immutable(); + channel = channel == null ? "" : channel.strip(); + if (channel.length() > TwitchChannel.MAX_INPUT_LENGTH) channel = ""; + } + + private static SaveScreenChannelPayload read(RegistryFriendlyByteBuf buffer) { + return new SaveScreenChannelPayload(buffer.readBlockPos(), + buffer.readUtf(TwitchChannel.MAX_INPUT_LENGTH)); + } + + private static void write(RegistryFriendlyByteBuf buffer, SaveScreenChannelPayload payload) { + buffer.writeBlockPos(payload.pos); + buffer.writeUtf(payload.channel, TwitchChannel.MAX_INPUT_LENGTH); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java new file mode 100644 index 0000000..42a0a31 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java @@ -0,0 +1,57 @@ +package fr.koka99cab.sanctuary26.zevent.network; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout; +import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity; +import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.phys.Vec3; + +public final class ZeventNetworking { + public static final int PROTOCOL_VERSION = 1; + private static final double MAX_INTERACTION_DISTANCE_SQR = 64.0D; + private static boolean initialized; + + private ZeventNetworking() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + PayloadTypeRegistry.clientboundPlay().register(OpenScreenOptionsPayload.TYPE, + OpenScreenOptionsPayload.STREAM_CODEC); + PayloadTypeRegistry.serverboundPlay().register(SaveScreenChannelPayload.TYPE, + SaveScreenChannelPayload.STREAM_CODEC); + ServerPlayNetworking.registerGlobalReceiver(SaveScreenChannelPayload.TYPE, (payload, context) -> + context.server().execute(() -> saveChannel(context.player(), payload))); + } + + public static void openOptions(ServerPlayer player, ZeventScreenBlockEntity screen) { + if (player.distanceToSqr(Vec3.atCenterOf(screen.getBlockPos())) > MAX_INTERACTION_DISTANCE_SQR + || !ServerPlayNetworking.canSend(player, OpenScreenOptionsPayload.TYPE)) return; + ServerPlayNetworking.send(player, new OpenScreenOptionsPayload( + screen.getBlockPos(), screen.channel(), screen.writable())); + } + + private static void saveChannel(ServerPlayer player, SaveScreenChannelPayload payload) { + if (player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > MAX_INTERACTION_DISTANCE_SQR) { + player.sendOverlayMessage(Component.translatable("message.zevent.screen.too_far")); + return; + } + if (!(player.level().getBlockEntity(payload.pos()) instanceof ZeventScreenBlockEntity screen) + || !ZeventScreenLayout.isController(screen.getBlockState())) { + player.sendOverlayMessage(Component.translatable("message.zevent.screen.missing")); + return; + } + String normalized = TwitchChannel.normalize(payload.channel()).orElse(null); + if (normalized == null) return; + if (!screen.setChannel(normalized)) { + player.sendOverlayMessage(Component.translatable("message.zevent.screen.newer_data")); + return; + } + player.sendOverlayMessage(Component.translatable("screen.zevent.options.saved")); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java new file mode 100644 index 0000000..22108bc --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java @@ -0,0 +1,69 @@ +package fr.koka99cab.sanctuary26.zevent.registry; + +import fr.koka99cab.sanctuary26.zevent.ZeventMod; +import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock; +import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity; +import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.material.MapColor; + +public final class ZeventRegistries { + private static final Identifier SCREEN_ID = ZeventMod.id("zevent_screen"); + public static final ZeventScreenBlock ZEVENT_SCREEN = Registry.register( + BuiltInRegistries.BLOCK, + blockKey(SCREEN_ID), + new ZeventScreenBlock(BlockBehaviour.Properties.of() + .mapColor(MapColor.COLOR_BLACK) + .strength(3.5F, 8.0F) + .sound(SoundType.METAL) + .noOcclusion() + .setId(blockKey(SCREEN_ID))) + ); + public static final Item ZEVENT_SCREEN_ITEM = Registry.register( + BuiltInRegistries.ITEM, + itemKey(SCREEN_ID), + new BlockItem(ZEVENT_SCREEN, new Item.Properties().useBlockDescriptionPrefix().setId(itemKey(SCREEN_ID))) + ); + public static final BlockEntityType ZEVENT_SCREEN_BLOCK_ENTITY = Registry.register( + BuiltInRegistries.BLOCK_ENTITY_TYPE, + ZeventMod.id("zevent_screen"), + FabricBlockEntityTypeBuilder.create(ZeventScreenBlockEntity::new, ZEVENT_SCREEN).build() + ); + public static final CreativeModeTab CREATIVE_TAB = Registry.register( + BuiltInRegistries.CREATIVE_MODE_TAB, + ResourceKey.create(Registries.CREATIVE_MODE_TAB, ZeventMod.id("zevent")), + CreativeModeTab.builder(CreativeModeTab.Row.TOP, 0) + .title(Component.translatable("itemGroup.zevent.zevent")) + .icon(() -> new ItemStack(ZEVENT_SCREEN_ITEM)) + .displayItems((parameters, output) -> output.accept(ZEVENT_SCREEN_ITEM)) + .build() + ); + + private ZeventRegistries() { + } + + public static void initialize() { + // Class initialization owns all registry writes. + } + + private static ResourceKey blockKey(Identifier id) { + return ResourceKey.create(Registries.BLOCK, id); + } + + private static ResourceKey itemKey(Identifier id) { + return ResourceKey.create(Registries.ITEM, id); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java new file mode 100644 index 0000000..df4659c --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java @@ -0,0 +1,58 @@ +package fr.koka99cab.sanctuary26.zevent.twitch; + +import java.net.URI; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +/** Converts user input to the only external identifier the server persists. */ +public final class TwitchChannel { + public static final String DEFAULT = "zeventfr"; + public static final int MAX_INPUT_LENGTH = 128; + public static final int MAX_NAME_LENGTH = 25; + private static final Pattern NAME = Pattern.compile("[a-z0-9_]{1," + MAX_NAME_LENGTH + "}"); + + private TwitchChannel() { + } + + public static Optional normalize(String input) { + if (input == null) return Optional.empty(); + String candidate = input.strip().toLowerCase(Locale.ROOT); + if (candidate.isEmpty() || candidate.length() > MAX_INPUT_LENGTH) return Optional.empty(); + + if (candidate.startsWith("@")) candidate = candidate.substring(1); + if (candidate.startsWith("twitch.tv/") || candidate.startsWith("www.twitch.tv/")) { + candidate = "https://" + candidate; + } + if (candidate.startsWith("http://") || candidate.startsWith("https://")) { + candidate = channelFromUrl(candidate).orElse(""); + } + int query = candidate.indexOf('?'); + if (query >= 0) candidate = candidate.substring(0, query); + int fragment = candidate.indexOf('#'); + if (fragment >= 0) candidate = candidate.substring(0, fragment); + candidate = candidate.replaceAll("^/+|/+$", ""); + return NAME.matcher(candidate).matches() ? Optional.of(candidate) : Optional.empty(); + } + + public static boolean valid(String channel) { + return channel != null && NAME.matcher(channel).matches(); + } + + private static Optional channelFromUrl(String input) { + try { + URI uri = URI.create(input); + String host = uri.getHost(); + if (host == null || !(host.equals("twitch.tv") || host.equals("www.twitch.tv"))) { + return Optional.empty(); + } + String path = uri.getPath(); + if (path == null) return Optional.empty(); + String[] parts = path.split("/"); + for (String part : parts) if (!part.isBlank()) return Optional.of(part); + } catch (IllegalArgumentException ignored) { + // Invalid URLs are rejected by the common name validation below. + } + return Optional.empty(); + } +} diff --git a/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json b/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json new file mode 100644 index 0000000..82ea0e4 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json @@ -0,0 +1,8 @@ +{ + "multipart": [ + { "when": { "facing": "north" }, "apply": { "model": "zevent:block/zevent_screen" } }, + { "when": { "facing": "east" }, "apply": { "model": "zevent:block/zevent_screen", "y": 90 } }, + { "when": { "facing": "south" }, "apply": { "model": "zevent:block/zevent_screen", "y": 180 } }, + { "when": { "facing": "west" }, "apply": { "model": "zevent:block/zevent_screen", "y": 270 } } + ] +} diff --git a/zevent/src/main/resources/assets/zevent/icon.png b/zevent/src/main/resources/assets/zevent/icon.png new file mode 100644 index 0000000..bb44706 Binary files /dev/null and b/zevent/src/main/resources/assets/zevent/icon.png differ diff --git a/zevent/src/main/resources/assets/zevent/items/zevent_screen.json b/zevent/src/main/resources/assets/zevent/items/zevent_screen.json new file mode 100644 index 0000000..0ef0d1a --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/items/zevent_screen.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "zevent:block/zevent_screen" + } +} diff --git a/zevent/src/main/resources/assets/zevent/lang/en_us.json b/zevent/src/main/resources/assets/zevent/lang/en_us.json new file mode 100644 index 0000000..af11973 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/en_us.json @@ -0,0 +1,17 @@ +{ + "mod.zevent.description": "Streams ZEVENT Twitch channels on a 9x5 wall screen.", + "itemGroup.zevent.zevent": "ZEVENT", + "block.zevent.zevent_screen": "9x5 ZEVENT Screen", + "screen.zevent.options.title": "ZEVENT Screen", + "screen.zevent.options.channel": "Twitch channel", + "screen.zevent.options.hint": "Name or twitch.tv/channel URL", + "screen.zevent.options.official": "Official ZEVENT", + "screen.zevent.options.zerator": "ZeratoR", + "screen.zevent.options.current": "Now playing: twitch.tv/%s", + "screen.zevent.options.invalid": "Invalid channel name (1–25 characters: a-z, 0-9, _).", + "screen.zevent.options.saved": "Twitch channel updated.", + "message.zevent.screen.too_far": "You are too far away from the screen.", + "message.zevent.screen.missing": "The ZEVENT screen no longer exists.", + "message.zevent.screen.newer_data": "This screen uses newer data and is read-only.", + "message.zevent.player.initializing": "Initializing the Twitch player…" +} diff --git a/zevent/src/main/resources/assets/zevent/lang/fr_fr.json b/zevent/src/main/resources/assets/zevent/lang/fr_fr.json new file mode 100644 index 0000000..7c0a796 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/fr_fr.json @@ -0,0 +1,17 @@ +{ + "mod.zevent.description": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.", + "itemGroup.zevent.zevent": "ZEVENT", + "block.zevent.zevent_screen": "Écran ZEVENT 9×5", + "screen.zevent.options.title": "Écran ZEVENT", + "screen.zevent.options.channel": "Chaîne Twitch", + "screen.zevent.options.hint": "Nom ou URL twitch.tv/chaine", + "screen.zevent.options.official": "ZEVENT officiel", + "screen.zevent.options.zerator": "ZeratoR", + "screen.zevent.options.current": "Diffusion actuelle : twitch.tv/%s", + "screen.zevent.options.invalid": "Nom de chaîne invalide (1–25 caractères : a-z, 0-9, _).", + "screen.zevent.options.saved": "Chaîne Twitch mise à jour.", + "message.zevent.screen.too_far": "Tu es trop loin de l'écran.", + "message.zevent.screen.missing": "L'écran ZEVENT n'existe plus.", + "message.zevent.screen.newer_data": "Cet écran utilise des données plus récentes et reste en lecture seule.", + "message.zevent.player.initializing": "Initialisation du lecteur Twitch…" +} diff --git a/zevent/src/main/resources/assets/zevent/lang/ru_ru.json b/zevent/src/main/resources/assets/zevent/lang/ru_ru.json new file mode 100644 index 0000000..304f8c7 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/ru_ru.json @@ -0,0 +1,17 @@ +{ + "mod.zevent.description": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5.", + "itemGroup.zevent.zevent": "ZEVENT", + "block.zevent.zevent_screen": "Экран ZEVENT 9×5", + "screen.zevent.options.title": "Экран ZEVENT", + "screen.zevent.options.channel": "Канал Twitch", + "screen.zevent.options.hint": "Имя или URL twitch.tv/channel", + "screen.zevent.options.official": "Официальный ZEVENT", + "screen.zevent.options.zerator": "ZeratoR", + "screen.zevent.options.current": "Сейчас: twitch.tv/%s", + "screen.zevent.options.invalid": "Неверное имя канала (1–25 символов: a-z, 0-9, _).", + "screen.zevent.options.saved": "Канал Twitch обновлён.", + "message.zevent.screen.too_far": "Вы слишком далеко от экрана.", + "message.zevent.screen.missing": "Экран ZEVENT больше не существует.", + "message.zevent.screen.newer_data": "Этот экран использует более новые данны и доступен только для чтения.", + "message.zevent.player.initializing": "Инициализация плеера Twitch…" +} diff --git a/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json b/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json new file mode 100644 index 0000000..2ccf859 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json @@ -0,0 +1,21 @@ +{ + "parent": "minecraft:block/block", + "textures": { + "particle": "minecraft:block/black_concrete", + "screen": "minecraft:block/black_concrete" + }, + "elements": [ + { + "from": [0, 0, 14], + "to": [16, 16, 16], + "faces": { + "north": { "texture": "#screen", "cullface": "north" }, + "south": { "texture": "#screen", "cullface": "south" }, + "west": { "texture": "#screen", "cullface": "west" }, + "east": { "texture": "#screen", "cullface": "east" }, + "up": { "texture": "#screen", "cullface": "up" }, + "down": { "texture": "#screen", "cullface": "down" } + } + } + ] +} diff --git a/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json b/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json new file mode 100644 index 0000000..25681e1 --- /dev/null +++ b/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "zevent:zevent_screen" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ], + "random_sequence": "zevent:blocks/zevent_screen" +} diff --git a/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json b/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json new file mode 100644 index 0000000..f688811 --- /dev/null +++ b/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "building", + "pattern": [ + "BBB", + "GTG", + "BRB" + ], + "key": { + "B": "minecraft:black_concrete", + "G": "minecraft:tinted_glass", + "T": "minecraft:redstone_torch", + "R": "minecraft:redstone_block" + }, + "result": { + "id": "zevent:zevent_screen", + "count": 1 + } +} diff --git a/zevent/src/main/resources/fabric.mod.json b/zevent/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..f3cfd72 --- /dev/null +++ b/zevent/src/main/resources/fabric.mod.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "id": "zevent", + "version": "${version}", + "name": "ZEVENT", + "description": "Watch ZEVENT Twitch channels on a 9x5 in-world screen.", + "authors": [ + "KOKA99CAB" + ], + "license": "GPL-3.0-or-later", + "icon": "assets/zevent/icon.png", + "environment": "*", + "entrypoints": { + "main": [ + "fr.koka99cab.sanctuary26.zevent.ZeventMod" + ], + "client": [ + "fr.koka99cab.sanctuary26.zevent.client.ZeventClient" + ] + }, + "depends": { + "fabricloader": ">=0.19.3", + "minecraft": "~26.2", + "java": ">=25", + "fabric-api": ">=0.159.0+26.2", + "mcef-modern": ">=0.3.3" + }, + "custom": { + "sanctuary26": { + "system_type": "server-authoritative-screen", + "namespace": "zevent", + "data_version": 1, + "network_protocol": 1, + "lifecycle": "${lifecycle}", + "pack_version": "${pack_version}", + "distribution": "standalone-event-mod", + "descriptions": { + "fr_fr": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.", + "en_us": "Streams ZEVENT Twitch channels on a 9x5 wall screen.", + "ru_ru": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5." + } + } + } +} diff --git a/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java new file mode 100644 index 0000000..e7980c5 --- /dev/null +++ b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java @@ -0,0 +1,30 @@ +package fr.koka99cab.sanctuary26.zevent.client.twitch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; + +class TwitchPlayerPageServerTest { + @Test + void servesOnlyTheBoundedOfficialPlayerPage() throws Exception { + TwitchPlayerPageServer server = new TwitchPlayerPageServer(); + try (HttpClient client = HttpClient.newHttpClient()) { + String url = server.playerUrl("zeventfr"); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("https://player.twitch.tv/js/embed/v1.js")); + assertTrue(response.body().contains("const initialChannel=\"zeventfr\"")); + assertTrue(response.headers().firstValue("Content-Security-Policy").isPresent()); + assertThrows(IllegalArgumentException.class, () -> server.playerUrl("bad-channel!")); + } finally { + server.stop(); + } + } +} diff --git a/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java new file mode 100644 index 0000000..f6b4d09 --- /dev/null +++ b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java @@ -0,0 +1,22 @@ +package fr.koka99cab.sanctuary26.zevent.twitch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TwitchChannelTest { + @Test + void normalizesNamesAndOfficialUrls() { + assertEquals("zeventfr", TwitchChannel.normalize(" @ZEventFR ").orElseThrow()); + assertEquals("zerator", TwitchChannel.normalize("https://www.twitch.tv/ZeratoR/videos").orElseThrow()); + assertEquals("etoiles", TwitchChannel.normalize("twitch.tv/etoiles?ref=test").orElseThrow()); + } + + @Test + void rejectsForeignUrlsAndUnboundedNames() { + assertTrue(TwitchChannel.normalize("https://example.com/zeventfr").isEmpty()); + assertTrue(TwitchChannel.normalize("bad-channel!").isEmpty()); + assertTrue(TwitchChannel.normalize("a".repeat(TwitchChannel.MAX_NAME_LENGTH + 1)).isEmpty()); + } +}