├── src └── main │ ├── resources │ └── .gitkeep │ ├── resources-filtered │ └── plugin.yml │ └── java │ └── io │ └── github │ └── satoshinm │ └── WebSandboxMC │ ├── bukkit │ ├── EntityListener.java │ ├── PlayersListener.java │ ├── BlockListener.java │ ├── ClickableLinks.java │ ├── WebSandboxPlugin.java │ ├── WsCommand.java │ └── SettingsBukkit.java │ ├── Settings.java │ ├── ws │ ├── WebSocketServerInitializer.java │ ├── WebSocketFrameHandler.java │ ├── WebSocketIndexPageHandler.java │ └── WebSocketServerThread.java │ ├── sponge │ ├── WebSandboxSpongePlugin.java │ └── SettingsSponge.java │ └── bridge │ ├── PlayersBridge.java │ ├── WebPlayerBridge.java │ └── BlockBridge.java ├── screenshot.png ├── circle.yml ├── .gitignore ├── LICENSE ├── pom.xml └── README.md /src/main/resources/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satoshinm/WebSandboxMC/HEAD/screenshot.png -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | maven: circleci/maven@0.0.12 5 | 6 | workflows: 7 | maven_test: 8 | jobs: 9 | - maven/test # checkout, build, test, and upload test results 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Eclipse stuff 2 | /.classpath 3 | /.project 4 | /.settings 5 | 6 | # netbeans 7 | /nbproject 8 | 9 | # maven 10 | /target 11 | 12 | # ant 13 | /bin 14 | /dist 15 | 16 | # vim 17 | .*.sw[a-p] 18 | 19 | # maven 20 | dependency-reduced-pom.xml 21 | -------------------------------------------------------------------------------- /src/main/resources-filtered/plugin.yml: -------------------------------------------------------------------------------- 1 | name: WebSandboxMC 2 | main: io.github.satoshinm.WebSandboxMC.bukkit.WebSandboxPlugin 3 | version: ${version} 4 | api-version: 1.14 5 | website: https://www.spigotmc.org/resources/websandboxmc.39415/ 6 | commands: 7 | websandbox: 8 | description: Controls the WebSandbox WebGL HTML5 web interface. 9 | usage: "Usage: /websandbox list|tp|kick|clear|auth" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017 Satoshi N. M 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/EntityListener.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bukkit; 2 | 3 | import io.github.satoshinm.WebSandboxMC.bridge.WebPlayerBridge; 4 | import org.bukkit.entity.Entity; 5 | import org.bukkit.entity.Player; 6 | import org.bukkit.event.EventHandler; 7 | import org.bukkit.event.Listener; 8 | import org.bukkit.event.entity.EntityDamageEvent; 9 | import org.bukkit.event.entity.EntityDeathEvent; 10 | import org.bukkit.event.player.PlayerShearEntityEvent; 11 | 12 | public class EntityListener implements Listener { 13 | 14 | private final WebPlayerBridge webPlayerBridge; 15 | 16 | public EntityListener(WebPlayerBridge webPlayerBridge) { 17 | this.webPlayerBridge = webPlayerBridge; 18 | } 19 | 20 | @EventHandler(ignoreCancelled = true) 21 | public void onEntityDeath(EntityDeathEvent event) { 22 | Entity entity = event.getEntity(); 23 | 24 | String username = webPlayerBridge.entityId2Username.get(entity.getEntityId()); 25 | if (username == null) { 26 | return; 27 | } 28 | 29 | EntityDamageEvent entityDamageEvent = entity.getLastDamageCause(); 30 | // TODO: how to get killer? 31 | EntityDamageEvent.DamageCause damageCause = entityDamageEvent != null ? entityDamageEvent.getCause() : null; 32 | 33 | webPlayerBridge.notifyDied(username, damageCause); 34 | } 35 | 36 | @EventHandler(ignoreCancelled = true) 37 | public void onShear(PlayerShearEntityEvent event) { 38 | Entity entity = event.getEntity(); 39 | 40 | String username = webPlayerBridge.entityId2Username.get(entity.getEntityId()); 41 | if (username == null) { 42 | return; 43 | } 44 | 45 | Player player = event.getPlayer(); 46 | String playerName = player.getDisplayName(); 47 | 48 | webPlayerBridge.notifySheared(username, playerName); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/PlayersListener.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bukkit; 2 | 3 | import io.github.satoshinm.WebSandboxMC.bridge.PlayersBridge; 4 | import org.bukkit.entity.Player; 5 | import org.bukkit.event.EventHandler; 6 | import org.bukkit.event.Listener; 7 | import org.bukkit.event.player.AsyncPlayerChatEvent; 8 | import org.bukkit.event.player.PlayerJoinEvent; 9 | import org.bukkit.event.player.PlayerMoveEvent; 10 | import org.bukkit.event.player.PlayerQuitEvent; 11 | 12 | public class PlayersListener implements Listener { 13 | 14 | private final PlayersBridge playersBridge; 15 | 16 | public PlayersListener(PlayersBridge playersBridge) { 17 | this.playersBridge = playersBridge; 18 | } 19 | 20 | @EventHandler(ignoreCancelled = true) 21 | public void onChat(AsyncPlayerChatEvent event) { 22 | String formattedMessage = event.getFormat().format(event.getMessage()); 23 | formattedMessage = "<" + event.getPlayer().getDisplayName() + "> " + formattedMessage; // TODO: why doesn't getFormat() take care of this? 24 | 25 | playersBridge.notifyChat(formattedMessage); 26 | } 27 | 28 | @EventHandler(ignoreCancelled = true) 29 | public void onMove(PlayerMoveEvent event) { 30 | Player player = event.getPlayer(); 31 | 32 | playersBridge.notifyMove(player.getEntityId(), player.getDisplayName(), event.getTo()); 33 | } 34 | 35 | @EventHandler(ignoreCancelled = true) 36 | public void onJoin(PlayerJoinEvent event) { 37 | Player player = event.getPlayer(); 38 | 39 | playersBridge.notifyAdd(player.getEntityId(), player.getDisplayName(), player.getLocation()); 40 | } 41 | 42 | @EventHandler(ignoreCancelled = true) 43 | public void onQuit(PlayerQuitEvent event) { 44 | Player player = event.getPlayer(); 45 | 46 | playersBridge.notifyDelete(player.getEntityId()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/BlockListener.java: -------------------------------------------------------------------------------- 1 | 2 | package io.github.satoshinm.WebSandboxMC.bukkit; 3 | 4 | import io.github.satoshinm.WebSandboxMC.bridge.BlockBridge; 5 | import org.bukkit.Location; 6 | import org.bukkit.Material; 7 | import org.bukkit.block.Block; 8 | import org.bukkit.event.EventHandler; 9 | import org.bukkit.event.Listener; 10 | import org.bukkit.event.block.BlockBreakEvent; 11 | import org.bukkit.event.block.BlockPlaceEvent; 12 | import org.bukkit.event.block.SignChangeEvent; 13 | 14 | public class BlockListener implements Listener { 15 | 16 | public BlockBridge blockBridge; 17 | 18 | public BlockListener(BlockBridge blockBridge) { 19 | this.blockBridge = blockBridge; 20 | } 21 | 22 | @EventHandler(ignoreCancelled = true) 23 | public void onBlockBreak(BlockBreakEvent event) { 24 | Block block = event.getBlock(); 25 | Location location = block.getLocation(); 26 | blockBridge.notifyBlockUpdate(location, Material.AIR, null); 27 | } 28 | 29 | @EventHandler(ignoreCancelled = true) 30 | public void onBlockPlace(BlockPlaceEvent event) { 31 | Block block = event.getBlock(); 32 | 33 | blockBridge.notifyBlockUpdate(block.getLocation(), block.getType(), block.getState()); 34 | } 35 | @EventHandler(ignoreCancelled = true) 36 | public void onSignChange(SignChangeEvent event) { 37 | Block block = event.getBlock(); 38 | 39 | blockBridge.notifySignChange(block.getLocation(), block.getType(), block.getState(), event.getLines()); 40 | } 41 | 42 | // TODO: BlockBurnEvent 43 | // TODO: BlockExplodeEvent 44 | // TODO: BlockFadeEvent 45 | // TODO: BlockFromToEvent 46 | // TODO: BlockFormEvent 47 | // TODO: BlockGrowEvent 48 | // TODO: BlockIgniteEvent 49 | // TODO: BlockMultiPlaceEvent 50 | // TODO: BlockPhysicsEvent 51 | // TODO: BlockPiston*Event 52 | // TODO: BlockRedstoneEvent 53 | // TODO: BlockSpreadEvent 54 | // TODO: CauldronLevelChangeEvent 55 | // TODO: FurnaceBurnEvent (change light levels) 56 | // TODO: LeavesDecayEvent 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/ClickableLinks.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bukkit; 2 | 3 | import net.md_5.bungee.api.chat.ClickEvent; 4 | import net.md_5.bungee.api.chat.HoverEvent; 5 | import net.md_5.bungee.api.chat.TextComponent; 6 | import net.md_5.bungee.api.chat.hover.content.Text; 7 | import org.bukkit.Bukkit; 8 | import org.bukkit.entity.Player; 9 | import org.json.simple.JSONObject; 10 | 11 | public class ClickableLinks { 12 | public static void sendLink(Player player, String url, boolean clickableLinksTellraw) { 13 | String linkText = "Click here to login"; 14 | String hoverText = "Login to the web sandbox as " + player.getName(); 15 | 16 | // There are two strategies since TextComponents fails with on Glowstone with an error: 17 | // java.lang.UnsupportedOperationException: Not supported yet. 18 | // at org.bukkit.entity.Player$Spigot.sendMessage(Player.java:1734) 19 | // see https://github.com/GlowstoneMC/Glowkit-Legacy/pull/8 20 | if (clickableLinksTellraw) { 21 | JSONObject json = new JSONObject(); 22 | json.put("text", linkText); 23 | json.put("bold", true); 24 | 25 | JSONObject clickEventJson = new JSONObject(); 26 | clickEventJson.put("action", "open_url"); 27 | clickEventJson.put("value", url); 28 | json.put("clickEvent", clickEventJson); 29 | 30 | JSONObject hoverEventJson = new JSONObject(); 31 | hoverEventJson.put("action", "show_text"); 32 | JSONObject hoverTextObject = new JSONObject(); 33 | hoverTextObject.put("text", hoverText); 34 | hoverEventJson.put("value", hoverTextObject); 35 | json.put("hoverEvent", hoverEventJson); 36 | 37 | Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), "tellraw " + player.getName() + " " + json.toJSONString()); 38 | } else { 39 | TextComponent message = new TextComponent(linkText); 40 | message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); 41 | message.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(hoverText))); 42 | message.setBold(true); 43 | 44 | player.spigot().sendMessage(message); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC; 2 | 3 | import java.io.File; 4 | import java.util.ArrayList; 5 | import java.util.HashMap; 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.logging.Level; 9 | 10 | abstract public class Settings { 11 | // User configurable settings 12 | public int httpPort = 4081; 13 | public String publicURL = "http://localhost:" + httpPort + "/"; 14 | public boolean takeover = false; 15 | public String unbindMethod = "console.getServerConnection.b"; 16 | 17 | public boolean debug = false; 18 | public boolean nettyLogInfo = false; 19 | public boolean usePermissions = false; 20 | public String entityClassName = "Sheep"; 21 | public boolean setCustomNames = true; 22 | public boolean disableGravity = true; 23 | public boolean disableAI = true; 24 | public boolean entityMoveSandbox = true; 25 | public boolean entityDieDisconnect = false; 26 | 27 | // Send blocks around this area in the Bukkit world 28 | public String world = ""; 29 | public int x_center = 0; 30 | public int y_center = 75; 31 | public int z_center = 0; 32 | 33 | // of this radius, +/- 34 | public int radius = 16; 35 | 36 | public boolean clickableLinks = true; 37 | public boolean clickableLinksTellraw = false; 38 | 39 | // raised this amount in the web world, so it is clearly distinguished from the client-generated terrain 40 | public int y_offset = 20; 41 | 42 | public boolean allowAnonymous = true; 43 | public boolean checkIPBans = true; 44 | public boolean allowBreakPlaceBlocks = true; 45 | public List unbreakableBlocks = new ArrayList(); 46 | public boolean allowSigns = true; 47 | public boolean allowChatting = true; 48 | public boolean seeChat = true; 49 | public boolean seePlayers = true; 50 | public boolean seeTime = true; 51 | public boolean creativeMode = true; 52 | 53 | public Map blocksToWebOverride = new HashMap(); 54 | public boolean warnMissing = true; 55 | 56 | // Automatic settings 57 | public String textureURL = null; 58 | public File pluginDataFolder = null; 59 | 60 | // Implementation-defined utility methods 61 | abstract public void log(Level level, String message); 62 | abstract public void scheduleSyncTask(Runnable runnable); 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerInitializer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 The Netty Project 3 | * 4 | * The Netty Project licenses this file to you under the Apache License, 5 | * version 2.0 (the "License"); you may not use this file except in compliance 6 | * with the License. You may obtain a copy of the License at: 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations 14 | * under the License. 15 | */ 16 | package io.github.satoshinm.WebSandboxMC.ws; 17 | 18 | import io.netty.channel.ChannelInitializer; 19 | import io.netty.channel.ChannelPipeline; 20 | import io.netty.channel.socket.SocketChannel; 21 | import io.netty.handler.codec.http.HttpObjectAggregator; 22 | import io.netty.handler.codec.http.HttpServerCodec; 23 | import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; 24 | import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler; 25 | import io.netty.handler.ssl.SslContext; 26 | 27 | import java.io.File; 28 | 29 | /** 30 | */ 31 | public class WebSocketServerInitializer extends ChannelInitializer { 32 | 33 | // WebSockets, unlike plain sockets, support the concept of a "path", since they are 34 | // layered over HTTP. To avoid conflicting with serving the HTML and JavaScript resources 35 | // at /, use /craftws for the WebSocket (TODO: possible to overload HTML and WS with Netty 36 | // on the same URL? /), can connect in NetCraft using commands like this: 37 | // /online ws://localhost:4081/craftws 38 | // /online localhost 39 | private static final String WEBSOCKET_PATH = "/craftws"; 40 | 41 | private final SslContext sslCtx; 42 | private final WebSocketServerThread webSocketServerThread; 43 | private final File pluginDataFolder; 44 | private final boolean checkIPBans; 45 | 46 | public WebSocketServerInitializer(SslContext sslCtx, WebSocketServerThread webSocketServerThread, 47 | File pluginDataFolder, boolean checkIPBans) { 48 | this.sslCtx = sslCtx; 49 | this.webSocketServerThread = webSocketServerThread; 50 | this.pluginDataFolder = pluginDataFolder; 51 | this.checkIPBans = checkIPBans; 52 | } 53 | 54 | @Override 55 | public void initChannel(SocketChannel ch) throws Exception { 56 | ChannelPipeline pipeline = ch.pipeline(); 57 | if (sslCtx != null) { 58 | pipeline.addLast(sslCtx.newHandler(ch.alloc())); 59 | } 60 | pipeline.addLast(new HttpServerCodec()); 61 | pipeline.addLast(new HttpObjectAggregator(65536)); 62 | pipeline.addLast(new WebSocketServerCompressionHandler()); 63 | pipeline.addLast(new WebSocketServerProtocolHandler(WEBSOCKET_PATH, "binary", true)); 64 | pipeline.addLast(new WebSocketIndexPageHandler(pluginDataFolder)); 65 | pipeline.addLast(new WebSocketFrameHandler(webSocketServerThread, checkIPBans)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/sponge/WebSandboxSpongePlugin.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.sponge; 2 | 3 | import com.google.inject.Inject; 4 | import io.github.satoshinm.WebSandboxMC.Settings; 5 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 6 | import ninja.leaping.configurate.commented.CommentedConfigurationNode; 7 | import ninja.leaping.configurate.loader.ConfigurationLoader; 8 | import org.slf4j.Logger; 9 | import org.spongepowered.api.config.ConfigDir; 10 | import org.spongepowered.api.config.DefaultConfig; 11 | import org.spongepowered.api.event.Listener; 12 | import org.spongepowered.api.event.game.state.GameInitializationEvent; 13 | import org.spongepowered.api.event.game.state.GameStartingServerEvent; 14 | import org.spongepowered.api.plugin.Plugin; 15 | 16 | import java.nio.file.Path; 17 | 18 | @Plugin(id = "websandboxmc", 19 | name = "WebSandboxMC", 20 | description = "Web-based client providing an interactive glimpse of a part of your server using WebGL/HTML5", 21 | version = "1.8.0") 22 | public class WebSandboxSpongePlugin { 23 | 24 | @Inject 25 | public Logger logger; 26 | 27 | @Inject 28 | @DefaultConfig(sharedRoot = false) 29 | public Path defaultConfig; 30 | 31 | @Inject 32 | @DefaultConfig(sharedRoot = false) 33 | public ConfigurationLoader configManager; 34 | 35 | @Inject 36 | @ConfigDir(sharedRoot = false) 37 | private Path configDir; 38 | 39 | //@Inject 40 | //private Game game; 41 | 42 | private Settings settings; 43 | 44 | private WebSocketServerThread webSocketServerThread; 45 | 46 | @Listener 47 | public void onGameInit(GameInitializationEvent event) { 48 | logger.info("WebSandboxMC/Sponge starting"); 49 | logger.info("config path: " + configDir); 50 | 51 | settings = new SettingsSponge(this); 52 | } 53 | 54 | @Listener 55 | public void onServerStarting(GameStartingServerEvent event) { 56 | // https://docs.spongepowered.org/stable/en/plugin/lifecycle.html 57 | // "The server instance exists, and worlds are loaded" 58 | 59 | webSocketServerThread = new WebSocketServerThread(settings); 60 | 61 | /* TODO: factor out bukkit 62 | webSocketServerThread.blockBridge = new BlockBridge(webSocketServerThread, settings); 63 | webSocketServerThread.playersBridge = new PlayersBridge(webSocketServerThread, settings); 64 | webSocketServerThread.webPlayerBridge = new WebPlayerBridge(webSocketServerThread, settings); 65 | */ 66 | 67 | /* TODO: write for sponge 68 | // Register our events 69 | PluginManager pm = getServer().getPluginManager(); 70 | 71 | pm.registerEvents(new BlockListener(webSocketServerThread.blockBridge), plugin); 72 | pm.registerEvents(new PlayersListener(webSocketServerThread.playersBridge), plugin); 73 | pm.registerEvents(new EntityListener(webSocketServerThread.webPlayerBridge), plugin); 74 | 75 | // Register our commands 76 | getCommand("websandbox").setExecutor(new WsCommand(webSocketServerThread, settings.usePermissions)); 77 | */ 78 | 79 | // Run the websocket server 80 | webSocketServerThread.start(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketFrameHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 The Netty Project 3 | * 4 | * The Netty Project licenses this file to you under the Apache License, 5 | * version 2.0 (the "License"); you may not use this file except in compliance 6 | * with the License. You may obtain a copy of the License at: 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations 14 | * under the License. 15 | */ 16 | package io.github.satoshinm.WebSandboxMC.ws; 17 | 18 | import io.netty.buffer.ByteBuf; 19 | import io.netty.channel.*; 20 | import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; 21 | import io.netty.handler.codec.http.websocketx.WebSocketFrame; 22 | import org.bukkit.Bukkit; 23 | 24 | import java.util.Set; 25 | import java.util.logging.Level; 26 | 27 | public class WebSocketFrameHandler extends SimpleChannelInboundHandler { 28 | 29 | private final WebSocketServerThread webSocketServerThread; 30 | private final Set ipBans; 31 | private boolean checkIPBans; 32 | 33 | public WebSocketFrameHandler(WebSocketServerThread webSocketServerThread, boolean checkIPBans) { 34 | this.webSocketServerThread = webSocketServerThread; 35 | this.checkIPBans = checkIPBans; 36 | 37 | if (this.checkIPBans) { 38 | this.ipBans = Bukkit.getServer().getIPBans(); 39 | } else { 40 | this.ipBans = null; 41 | } 42 | } 43 | 44 | @Override 45 | public void channelRead0(final ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { 46 | webSocketServerThread.log(Level.FINEST, "channel read, frame="+frame); 47 | // TODO: log at INFO level if this the first data we received from a client (new first connection), to 48 | // help detect clients connecting but not sending authentication commands (in newPlayer) 49 | 50 | if (this.checkIPBans) { 51 | String ip = webSocketServerThread.getRemoteIP(ctx.channel()); 52 | if (this.ipBans.contains(ip)) { 53 | webSocketServerThread.sendLine(ctx.channel(), "T,Banned from server"); // TODO: show reason, getBanList 54 | return; 55 | } 56 | } 57 | 58 | if (frame instanceof BinaryWebSocketFrame) { 59 | ByteBuf content = frame.content(); 60 | 61 | byte[] bytes = new byte[content.capacity()]; 62 | content.getBytes(0, bytes); 63 | 64 | final String string = new String(bytes); 65 | webSocketServerThread.log(Level.FINEST, "received "+content.capacity()+" bytes: "+string); 66 | 67 | this.webSocketServerThread.scheduleSyncTask(new Runnable() { 68 | @Override 69 | public void run() { 70 | webSocketServerThread.handle(string, ctx); 71 | } 72 | }); 73 | } else { 74 | String message = "unsupported frame type: " + frame.getClass().getName(); 75 | throw new UnsupportedOperationException(message); 76 | } 77 | } 78 | 79 | @Override 80 | public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { 81 | cause.printStackTrace(); 82 | ctx.close(); 83 | } 84 | 85 | @Override 86 | public void channelInactive(final ChannelHandlerContext ctx) { 87 | webSocketServerThread.scheduleSyncTask(new Runnable() { 88 | @Override 89 | public void run() { 90 | webSocketServerThread.webPlayerBridge.clientDisconnected(ctx.channel()); 91 | } 92 | }); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.0.0 3 | io.github.satoshinm 4 | WebSandboxMC 5 | 2.0.0 6 | WebSandboxMC 7 | https://github.com/satoshinm/WebSandboxMC 8 | 9 | 10 | 11 | spigot-repo 12 | https://hub.spigotmc.org/nexus/content/repositories/snapshots/ 13 | 14 | 15 | spigot-sonatype-repo 16 | https://hub.spigotmc.org/nexus/content/repositories/sonatype-nexus-snapshots/ 17 | 18 | 19 | sponge-repo 20 | https://repo.spongepowered.org/maven/ 21 | 22 | 23 | 24 | 25 | 1.8 26 | 1.8 27 | UTF-8 28 | 4.1.59.Final 29 | 30 | 31 | 32 | 33 | org.spigotmc 34 | spigot-api 35 | 1.17.1-R0.1-SNAPSHOT 36 | jar 37 | provided 38 | 39 | 40 | org.spongepowered 41 | spongeapi 42 | 6.0.0 43 | provided 44 | 45 | 46 | io.netty 47 | netty-codec-http 48 | ${netty.version} 49 | compile 50 | 51 | 52 | io.netty 53 | netty-handler 54 | ${netty.version} 55 | compile 56 | 57 | 58 | com.googlecode.json-simple 59 | json-simple 60 | 1.1.1 61 | compile 62 | 63 | 64 | 65 | 66 | WebSandboxMC 67 | 68 | 69 | src/main/resources-filtered 70 | true 71 | 72 | 73 | src/main/resources 74 | false 75 | 76 | 77 | 78 | 79 | org.apache.maven.plugins 80 | maven-compiler-plugin 81 | 3.0 82 | 83 | 1.8 84 | 1.8 85 | 86 | 87 | 88 | org.apache.maven.plugins 89 | maven-shade-plugin 90 | 3.0.0 91 | 92 | 93 | package 94 | 95 | shade 96 | 97 | 98 | true 99 | 100 | 101 | io.netty 102 | 103 | io.github.satoshinm.WebSandboxMC.dep.io.netty 104 | 105 | 106 | 107 | org.json.simple 108 | 109 | io.github.satoshinm.WebSandboxMC.dep.org.json.simple 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/sponge/SettingsSponge.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.sponge; 2 | 3 | import com.google.common.reflect.TypeToken; 4 | import io.github.satoshinm.WebSandboxMC.Settings; 5 | import ninja.leaping.configurate.ConfigurationNode; 6 | import ninja.leaping.configurate.commented.CommentedConfigurationNode; 7 | import ninja.leaping.configurate.hocon.HoconConfigurationLoader; 8 | import ninja.leaping.configurate.loader.ConfigurationLoader; 9 | import ninja.leaping.configurate.objectmapping.ObjectMappingException; 10 | import org.spongepowered.api.scheduler.Task; 11 | 12 | import java.io.IOException; 13 | import java.util.logging.Level; 14 | 15 | public class SettingsSponge extends Settings { 16 | 17 | private WebSandboxSpongePlugin plugin; 18 | 19 | public SettingsSponge(WebSandboxSpongePlugin plugin) { 20 | this.plugin = plugin; 21 | 22 | 23 | ConfigurationLoader loader = 24 | HoconConfigurationLoader.builder().setPath(plugin.defaultConfig).build(); 25 | ConfigurationNode rootNode; 26 | try { 27 | rootNode = loader.load(); 28 | } catch (IOException e) { 29 | e.printStackTrace(); 30 | return; 31 | } 32 | 33 | this.httpPort = rootNode.getNode("http", "port").getInt(this.httpPort); 34 | this.takeover = rootNode.getNode("http", "takeover").getBoolean(this.takeover); 35 | this.unbindMethod = rootNode.getNode("http", "unbind_method").getString(this.unbindMethod); 36 | 37 | this.debug = rootNode.getNode("mc", "debug").getBoolean(this.debug); 38 | this.usePermissions = rootNode.getNode("mc", "use_permissions").getBoolean(this.usePermissions); 39 | 40 | this.entityClassName = rootNode.getNode("mc", "entity").getString(this.entityClassName); 41 | this.setCustomNames = rootNode.getNode("mc", "entity_custom_names").getBoolean(this.setCustomNames); 42 | this.disableGravity = rootNode.getNode("mc", "entity_disable_gravity").getBoolean(this.disableGravity); 43 | this.disableAI = rootNode.getNode("mc", "entity_disable_ai").getBoolean(this.disableAI); 44 | this.entityMoveSandbox = rootNode.getNode("mc", "entity_move_sandbox").getBoolean(this.entityMoveSandbox); 45 | this.entityDieDisconnect = rootNode.getNode("mc", "entity_die_disconnect").getBoolean(this.entityDieDisconnect); 46 | 47 | this.world = rootNode.getNode("mc", "world").getString(this.world); 48 | this.x_center = rootNode.getNode("mc", "x_center").getInt(this.x_center); 49 | this.y_center = rootNode.getNode("mc", "y_center").getInt(this.y_center); 50 | this.z_center = rootNode.getNode("mc", "z_center").getInt(this.z_center); 51 | this.radius = rootNode.getNode("mc", "radius").getInt(this.radius); 52 | 53 | this.y_offset = rootNode.getNode("nc", "y_offset").getInt(this.y_offset); 54 | 55 | this.allowBreakPlaceBlocks = rootNode.getNode("nc", "allow_break_place_blocks").getBoolean(this.allowBreakPlaceBlocks); 56 | try { 57 | this.unbreakableBlocks = rootNode.getNode("nc", "unbreakable_blocks").getList(TypeToken.of(String.class)); 58 | } catch (ObjectMappingException ex) { 59 | ex.printStackTrace(); 60 | } 61 | this.allowSigns = rootNode.getNode("nc", "allow_signs").getBoolean(this.allowSigns); 62 | this.allowChatting = rootNode.getNode("nc", "allow_chatting").getBoolean(this.allowChatting); 63 | this.seeChat = rootNode.getNode("nc", "see_chat").getBoolean(this.seeChat); 64 | this.seePlayers = rootNode.getNode("nc", "see_players").getBoolean(this.seePlayers); 65 | 66 | log(Level.INFO, "debug? " + this.debug); 67 | 68 | try { 69 | plugin.configManager.save(rootNode); 70 | } catch (IOException ex) { 71 | ex.printStackTrace(); 72 | } 73 | } 74 | 75 | @Override 76 | public void log(Level level, String message) { 77 | if (level == Level.FINEST && !debug) { 78 | return; 79 | } 80 | 81 | if (level == Level.FINEST) { 82 | plugin.logger.debug(message); 83 | } else if (level == Level.WARNING) { 84 | plugin.logger.warn(message); 85 | } else { 86 | plugin.logger.info(message); 87 | } 88 | } 89 | 90 | @Override 91 | public void scheduleSyncTask(Runnable runnable) { 92 | //TODO: Task.builder(). 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WebSandboxPlugin.java: -------------------------------------------------------------------------------- 1 | 2 | package io.github.satoshinm.WebSandboxMC.bukkit; 3 | 4 | import io.github.satoshinm.WebSandboxMC.Settings; 5 | import io.github.satoshinm.WebSandboxMC.bridge.BlockBridge; 6 | import io.github.satoshinm.WebSandboxMC.bridge.PlayersBridge; 7 | import io.github.satoshinm.WebSandboxMC.bridge.WebPlayerBridge; 8 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 9 | import org.bukkit.Bukkit; 10 | import org.bukkit.Server; 11 | import org.bukkit.plugin.Plugin; 12 | import org.bukkit.plugin.PluginManager; 13 | import org.bukkit.plugin.java.JavaPlugin; 14 | 15 | import java.lang.reflect.Field; 16 | import java.lang.reflect.InvocationTargetException; 17 | import java.lang.reflect.Method; 18 | import java.util.logging.Level; 19 | 20 | /** 21 | * Bukkit plugin class for WebSandboxMC 22 | * 23 | * Based on: https://github.com/bukkit/SamplePlugin/ 24 | * 25 | * Sample plugin for Bukkit 26 | * 27 | * @author Dinnerbone 28 | */ 29 | public class WebSandboxPlugin extends JavaPlugin { 30 | private WebSocketServerThread webSocketServerThread; 31 | 32 | @Override 33 | public void onDisable() { 34 | webSocketServerThread.webPlayerBridge.deleteAllEntities(); 35 | 36 | webSocketServerThread.interrupt(); 37 | } 38 | 39 | @Override 40 | public void onEnable() { 41 | final Settings settings = new SettingsBukkit(this); 42 | 43 | checkUnbind(settings); 44 | 45 | final Plugin plugin = this; 46 | 47 | // Run in a delayed task to ensure all worlds are loaded on startup (not only load: POSTWORLD). 48 | Bukkit.getScheduler().scheduleSyncDelayedTask(this, new Runnable() { 49 | @Override 50 | public void run() { 51 | 52 | webSocketServerThread = new WebSocketServerThread(settings); 53 | 54 | webSocketServerThread.blockBridge = new BlockBridge(webSocketServerThread, settings); 55 | webSocketServerThread.playersBridge = new PlayersBridge(webSocketServerThread, settings); 56 | webSocketServerThread.webPlayerBridge = new WebPlayerBridge(webSocketServerThread, settings); 57 | 58 | // Register our events 59 | PluginManager pm = getServer().getPluginManager(); 60 | 61 | pm.registerEvents(new BlockListener(webSocketServerThread.blockBridge), plugin); 62 | pm.registerEvents(new PlayersListener(webSocketServerThread.playersBridge), plugin); 63 | pm.registerEvents(new EntityListener(webSocketServerThread.webPlayerBridge), plugin); 64 | 65 | // Register our commands 66 | getCommand("websandbox").setExecutor(new WsCommand(webSocketServerThread, settings.usePermissions)); 67 | 68 | // Run the websocket server 69 | webSocketServerThread.start(); 70 | } 71 | }); 72 | } 73 | 74 | private void checkUnbind(Settings settings) { 75 | if (!settings.takeover) { 76 | return; 77 | } 78 | 79 | if (settings.unbindMethod == null || settings.unbindMethod.equals("")) { 80 | getLogger().log(Level.WARNING, "Port takeover is enabled but unbind_method is not set; ignoring"); 81 | return; 82 | } 83 | 84 | Server server = Bukkit.getServer(); 85 | 86 | getLogger().log(Level.INFO, "Squatting on port "+server.getPort()+" for server and web, trying unbind: "+settings.unbindMethod); 87 | 88 | String[] array = settings.unbindMethod.split("[.]"); 89 | if (array.length != 3) { 90 | getLogger().log(Level.WARNING, "Bad 'unbind' option set to: "+settings.unbindMethod+", see source for details."); 91 | return; // ignore it, they can read this source below for the format 92 | } 93 | 94 | // Reuse same port as Bukkit, repurposing it for our purposes 95 | settings.httpPort = server.getPort(); 96 | 97 | // Format is "field1Name.method2Name.method3Name", called on Bukkit.getServer() before startup 98 | String field1Name = array[0]; 99 | String method2Name = array[1]; 100 | String method3Name = array[2]; 101 | 102 | // First, "unbind" the previous port 103 | try { 104 | Field field = server.getClass().getDeclaredField(field1Name); 105 | field.setAccessible(true); 106 | Object console = field.get(server); 107 | 108 | Method method1 = console.getClass().getMethod(method2Name); 109 | Object object2 = method1.invoke(console); 110 | 111 | getLogger().log(Level.INFO, "Unbind server port..."); 112 | Method method2 = object2.getClass().getMethod(method3Name); 113 | method2.invoke(object2); 114 | 115 | } catch (NoSuchFieldException ex) { 116 | ex.printStackTrace(); 117 | } catch (IllegalAccessException ex) { 118 | ex.printStackTrace(); 119 | } catch (NoSuchMethodException ex) { 120 | ex.printStackTrace(); 121 | } catch (InvocationTargetException ex) { 122 | ex.printStackTrace(); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WsCommand.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bukkit; 2 | 3 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 4 | import io.netty.channel.Channel; 5 | import org.bukkit.Location; 6 | import org.bukkit.command.Command; 7 | import org.bukkit.command.CommandExecutor; 8 | import org.bukkit.command.CommandSender; 9 | import org.bukkit.entity.Entity; 10 | import org.bukkit.entity.Player; 11 | 12 | public class WsCommand implements CommandExecutor { 13 | private WebSocketServerThread webSocketServerThread; 14 | private boolean usePermissions; 15 | 16 | public WsCommand(WebSocketServerThread webSocketServerThread, boolean usePermissions) { 17 | this.webSocketServerThread = webSocketServerThread; 18 | this.usePermissions = usePermissions; 19 | } 20 | 21 | public boolean onCommand(CommandSender sender, Command command, String label, String[] split) { 22 | String subcommand = split.length == 0 ? "help" : split[0]; 23 | 24 | if (sender instanceof Player) { 25 | Player player = (Player) sender; 26 | if (!usePermissions) { 27 | if (!player.isOp() && !subcommand.equals("auth") && !subcommand.equals("help")) { 28 | sender.sendMessage("This /websandbox subcommand requires op"); 29 | return true; 30 | } 31 | } else { 32 | if (!player.hasPermission("websandbox.command." + subcommand)) { 33 | sender.sendMessage("/websandbox " + subcommand + " denied by permission"); 34 | return true; 35 | } 36 | } 37 | } 38 | 39 | if (subcommand.equals("list")) { 40 | int size = webSocketServerThread.webPlayerBridge.name2channel.size(); 41 | sender.sendMessage(size + " web player(s) connected:"); 42 | 43 | boolean verbose = split.length >= 2 && split[1].equals("verbose"); 44 | 45 | int i = 1; 46 | for (String name: webSocketServerThread.webPlayerBridge.name2channel.keySet()) { // TODO: sort? 47 | Channel channel = webSocketServerThread.webPlayerBridge.name2channel.get(name); 48 | 49 | String ip = webSocketServerThread.getRemoteIPandPort(channel); 50 | 51 | Entity entity = webSocketServerThread.webPlayerBridge.channelId2Entity.get(channel.id()); 52 | String entityInfo = ""; 53 | 54 | if (entity != null) { 55 | entityInfo += " entity "+entity.getEntityId(); 56 | if (verbose) { 57 | entityInfo += " "+entity.getClass().getName() + " at " + entity.getLocation(); 58 | } 59 | } 60 | 61 | sender.sendMessage(i + ". " + name + ", " + ip + entityInfo); 62 | ++i; 63 | } 64 | return true; 65 | } else if (subcommand.equals("tp")) { 66 | if (split.length < 2) { 67 | Location spawnLocation = webSocketServerThread.blockBridge.spawnLocation; 68 | if (sender instanceof Player) { 69 | Player player = (Player) sender; 70 | player.sendMessage("Taking you to web spawn location, " + spawnLocation); 71 | player.teleport(spawnLocation); 72 | } else { 73 | sender.sendMessage("Web spawn location is "+spawnLocation); 74 | } 75 | return true; 76 | } 77 | String name = split[1]; 78 | 79 | Channel channel = webSocketServerThread.webPlayerBridge.name2channel.get(name); 80 | if (channel == null) { 81 | sender.sendMessage("No such web user: " + name); 82 | return true; 83 | } 84 | 85 | Entity entity = webSocketServerThread.webPlayerBridge.channelId2Entity.get(channel.id()); 86 | if (entity == null) { 87 | sender.sendMessage("Web user "+name+" is connected, but has no spawned entity."); 88 | // TODO: allow tracking and teleporting independently of the Bukkit entity? 89 | return true; 90 | } 91 | 92 | Location location = entity.getLocation(); 93 | if (!(sender instanceof Player)) { 94 | sender.sendMessage("Web user "+name+"'s entity is located at: "+location); 95 | sender.sendMessage("Web user "+name+"'s entity is "+entity); 96 | return true; 97 | } 98 | Player player = (Player) sender; 99 | player.sendMessage("Teleporting you to "+name+" at "+location+" for "+entity); 100 | player.teleport(entity); 101 | 102 | webSocketServerThread.sendLine(channel, "T,"+player.getDisplayName()+" teleported to you"); 103 | } else if (subcommand.equals("kick")) { 104 | if (split.length < 2) { 105 | sender.sendMessage("Usage: /websandbox kick "); 106 | return true; 107 | } 108 | String name = split[1]; 109 | 110 | Channel channel = webSocketServerThread.webPlayerBridge.name2channel.get(name); 111 | if (channel == null) { 112 | sender.sendMessage("No such web user: " + name); 113 | return true; 114 | } 115 | 116 | sender.sendMessage("Kicking web client " + name); 117 | webSocketServerThread.sendLine(channel, "T,You were kicked by " + sender.getName()); 118 | webSocketServerThread.webPlayerBridge.clientDisconnected(channel); 119 | return true; 120 | } else if (subcommand.equals("clear")) { 121 | webSocketServerThread.webPlayerBridge.clearStaleEntities(sender); 122 | return true; 123 | } else if (subcommand.equals("auth")) { 124 | // TODO: non-ops should be able to run this command by default 125 | String name; 126 | 127 | if (!(sender instanceof Player)) { 128 | if (split.length < 2) { 129 | sender.sendMessage("Usage: /websandbox auth "); 130 | return true; 131 | } 132 | name = split[1]; 133 | } else { 134 | Player player = (Player) sender; 135 | name = player.getName(); 136 | } 137 | 138 | webSocketServerThread.webPlayerBridge.newClientAuthKey(name, sender); 139 | 140 | return true; 141 | } else { // help 142 | sender.sendMessage("/websandbox list [verbose] -- list all web users connected"); 143 | sender.sendMessage("/websandbox tp [] -- teleport to given web username, or web spawn location"); 144 | sender.sendMessage("/websandbox kick -- disconnect given web username"); 145 | sender.sendMessage("/websandbox clear -- remove stale entities in sandbox"); 146 | sender.sendMessage("/websandbox auth [] -- get authentication token to login non-anonymously"); 147 | // TODO: reload, reconfig commands 148 | } 149 | return false; 150 | } 151 | } 152 | 153 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/SettingsBukkit.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bukkit; 2 | 3 | import io.github.satoshinm.WebSandboxMC.Settings; 4 | import org.bukkit.Bukkit; 5 | import org.bukkit.configuration.ConfigurationSection; 6 | import org.bukkit.configuration.file.FileConfiguration; 7 | import org.bukkit.plugin.Plugin; 8 | 9 | import java.io.File; 10 | import java.util.Map; 11 | import java.util.logging.Level; 12 | 13 | public class SettingsBukkit extends Settings { 14 | private Plugin plugin; 15 | 16 | public SettingsBukkit(Plugin plugin) { 17 | this.plugin = plugin; 18 | 19 | // Configuration 20 | final FileConfiguration config = plugin.getConfig(); 21 | config.options().copyDefaults(true); 22 | 23 | config.addDefault("http.port", this.httpPort); 24 | config.addDefault("http.publicURL", this.publicURL); 25 | config.addDefault("http.takeover", this.takeover); 26 | config.addDefault("http.unbind_method", this.unbindMethod); 27 | 28 | config.addDefault("mc.debug", this.debug); 29 | config.addDefault("mc.netty_log_info", this.nettyLogInfo); 30 | config.addDefault("mc.use_permissions", this.usePermissions); 31 | config.addDefault("mc.entity", this.entityClassName); 32 | config.addDefault("mc.entity_custom_names", this.setCustomNames); 33 | config.addDefault("mc.entity_disable_gravity", this.disableGravity); 34 | config.addDefault("mc.entity_disable_ai", this.disableAI); 35 | config.addDefault("mc.entity_move_sandbox", this.entityMoveSandbox); 36 | config.addDefault("mc.entity_die_disconnect", this.entityDieDisconnect); 37 | config.addDefault("mc.world", this.world); 38 | config.addDefault("mc.x_center", this.x_center); 39 | config.addDefault("mc.y_center", this.y_center); 40 | config.addDefault("mc.z_center", this.z_center); 41 | config.addDefault("mc.radius", this.radius); 42 | config.addDefault("mc.clickable_links", this.clickableLinks); 43 | config.addDefault("mc.clickable_links_tellraw", this.clickableLinksTellraw); 44 | 45 | config.addDefault("nc.y_offset", this.y_offset); 46 | config.addDefault("nc.allow_anonymous", this.allowAnonymous); 47 | config.addDefault("nc.check_ip_bans", this.checkIPBans); 48 | config.addDefault("nc.allow_break_place_blocks", this.allowBreakPlaceBlocks); 49 | this.unbreakableBlocks.add("BEDROCK"); 50 | config.addDefault("nc.unbreakable_blocks", this.unbreakableBlocks); 51 | config.addDefault("nc.allow_signs", this.allowSigns); 52 | config.addDefault("nc.allow_chatting", this.allowChatting); 53 | config.addDefault("nc.see_chat", this.seeChat); 54 | config.addDefault("nc.see_players", this.seePlayers); 55 | config.addDefault("nc.see_time", this.seeTime); 56 | config.addDefault("nc.creative_mode", this.creativeMode); 57 | 58 | config.addDefault("nc.blocks_to_web_override", this.blocksToWebOverride); 59 | config.addDefault("nc.warn_missing_blocks_to_web", this.warnMissing); 60 | 61 | this.httpPort = plugin.getConfig().getInt("http.port"); 62 | this.publicURL = plugin.getConfig().getString("http.publicURL"); 63 | this.takeover = plugin.getConfig().getBoolean("http.takeover"); 64 | this.unbindMethod = plugin.getConfig().getString("http.unbind_method"); 65 | 66 | this.debug = plugin.getConfig().getBoolean("mc.debug"); 67 | this.nettyLogInfo = plugin.getConfig().getBoolean("mc.netty_log_info"); 68 | this.usePermissions = plugin.getConfig().getBoolean("mc.use_permissions"); 69 | 70 | this.entityClassName = plugin.getConfig().getString("mc.entity"); 71 | this.setCustomNames = plugin.getConfig().getBoolean("mc.entity_custom_names"); 72 | this.disableGravity = plugin.getConfig().getBoolean("mc.entity_disable_gravity"); 73 | this.disableAI = plugin.getConfig().getBoolean("mc.entity_disable_ai"); 74 | this.entityMoveSandbox = plugin.getConfig().getBoolean("mc.entity_move_sandbox"); 75 | this.entityDieDisconnect = plugin.getConfig().getBoolean("mc.entity_die_disconnect"); 76 | 77 | this.world = plugin.getConfig().getString("mc.world"); 78 | this.x_center = plugin.getConfig().getInt("mc.x_center"); 79 | this.y_center = plugin.getConfig().getInt("mc.y_center"); 80 | this.z_center = plugin.getConfig().getInt("mc.z_center"); 81 | this.radius = plugin.getConfig().getInt("mc.radius"); 82 | 83 | this.clickableLinks = plugin.getConfig().getBoolean("mc.clickable_links"); 84 | this.clickableLinksTellraw = plugin.getConfig().getBoolean("mc.clickable_links_tellraw"); 85 | 86 | this.y_offset = plugin.getConfig().getInt("nc.y_offset"); 87 | 88 | this.allowAnonymous = plugin.getConfig().getBoolean("nc.allow_anonymous"); 89 | this.checkIPBans = plugin.getConfig().getBoolean("nc.check_ip_bans"); 90 | this.allowBreakPlaceBlocks = plugin.getConfig().getBoolean("nc.allow_break_place_blocks"); 91 | this.unbreakableBlocks = plugin.getConfig().getStringList("nc.unbreakable_blocks"); 92 | this.allowSigns = plugin.getConfig().getBoolean("nc.allow_signs"); 93 | this.allowChatting = plugin.getConfig().getBoolean("nc.allow_chatting"); 94 | this.seeChat = plugin.getConfig().getBoolean("nc.see_chat"); 95 | this.seePlayers = plugin.getConfig().getBoolean("nc.see_players"); 96 | this.seeTime = plugin.getConfig().getBoolean("nc.see_time"); 97 | this.creativeMode = plugin.getConfig().getBoolean("nc.creative_mode"); 98 | if (plugin.getConfig().getConfigurationSection("nc.blocks_to_web") != null) { 99 | this.log(Level.WARNING, "blocks_to_web is now ignored, you can remove it or add to blocks_to_web_override instead"); 100 | } 101 | 102 | ConfigurationSection section = plugin.getConfig().getConfigurationSection("nc.blocks_to_web_override"); 103 | if (section != null) { 104 | for (Map.Entry entry : section.getValues(false).entrySet()) { 105 | this.blocksToWebOverride.put(entry.getKey(), entry.getValue()); 106 | } 107 | } 108 | this.warnMissing = plugin.getConfig().getBoolean("nc.warn_missing_blocks_to_web"); 109 | 110 | this.pluginDataFolder = plugin.getDataFolder(); 111 | File file = new File(this.pluginDataFolder, "textures.zip"); 112 | if (file.exists()) { 113 | //textureURL = plugin.getConfig().getString("nc.texture_url"); 114 | // Although arbitrary URLs could be configured, due to access control checks this becomes confusing, so 115 | // only allow auto-configuring as this special case to connect back to ourselves in /textures.zip. 116 | this.textureURL = "-"; 117 | } 118 | 119 | plugin.saveConfig(); 120 | } 121 | 122 | public void log(Level level, String message) { 123 | if (level == Level.FINEST && !debug) { 124 | return; 125 | } 126 | plugin.getLogger().log(level, message); 127 | } 128 | 129 | public void scheduleSyncTask(Runnable runnable) { 130 | if (!plugin.isEnabled()) { 131 | // When we are shutting down, the Netty channels go inactive, but we cannot schedule tasks when 132 | // the plugin is disabled so just return. 133 | return; 134 | } 135 | Bukkit.getScheduler().scheduleSyncDelayedTask(this.plugin, runnable); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bridge/PlayersBridge.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bridge; 2 | 3 | import io.github.satoshinm.WebSandboxMC.Settings; 4 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 5 | import io.netty.channel.Channel; 6 | import io.netty.channel.ChannelHandlerContext; 7 | import io.netty.channel.ChannelId; 8 | import org.bukkit.Bukkit; 9 | import org.bukkit.Location; 10 | import org.bukkit.entity.Entity; 11 | import org.bukkit.entity.Player; 12 | 13 | import java.lang.reflect.InvocationTargetException; 14 | import java.lang.reflect.Method; 15 | import java.util.*; 16 | 17 | /** 18 | * Bridges other Bukkit player's positions/names/chats to the web clients 19 | */ 20 | public class PlayersBridge { 21 | 22 | private final WebSocketServerThread webSocketServerThread; 23 | 24 | private Set playersInSandbox; 25 | private boolean allowChatting; 26 | private boolean seeChat; 27 | private boolean seePlayers; 28 | 29 | public PlayersBridge(WebSocketServerThread webSocketServerThread, Settings settings) { 30 | this.webSocketServerThread = webSocketServerThread; 31 | this.allowChatting = settings.allowChatting; 32 | this.seeChat = settings.seeChat; 33 | this.seePlayers = settings.seePlayers; 34 | 35 | this.playersInSandbox = new HashSet(); 36 | } 37 | 38 | public void sendPlayers(Channel channel) { 39 | if (!seePlayers) { 40 | return; 41 | } 42 | 43 | for (Player player: PlayersBridge.getOnlinePlayers()) { 44 | int id = player.getEntityId(); 45 | Location location = player.getLocation(); 46 | String name = player.getDisplayName(); 47 | 48 | if (this.playersInSandbox.contains(id)) { 49 | webSocketServerThread.sendLine(channel, "P," + id + "," + encodeLocation(location)); 50 | webSocketServerThread.sendLine(channel, "N," + id + "," + name); 51 | } 52 | } 53 | 54 | // Web players 55 | for (Map.Entry entry : webSocketServerThread.webPlayerBridge.channelId2Entity.entrySet()) { 56 | ChannelId channelId = entry.getKey(); 57 | 58 | if (channelId.equals(channel.id())) { 59 | // No third person, web players don't need entities for themselves 60 | continue; 61 | } 62 | 63 | Entity entity = entry.getValue(); 64 | 65 | int id = entity.getEntityId(); 66 | Location location = entity.getLocation(); 67 | String name = webSocketServerThread.webPlayerBridge.entityId2Username.get(id); 68 | if (name == null) name = "entity-"+id; 69 | 70 | webSocketServerThread.sendLine(channel, "P," + id + "," + encodeLocation(location)); 71 | webSocketServerThread.sendLine(channel, "N," + id + "," + name); 72 | } 73 | } 74 | 75 | private static Collection getOnlinePlayers() { 76 | try { 77 | return Bukkit.getServer().getOnlinePlayers(); 78 | } catch (NoSuchMethodError ex1) { 79 | // Older Bukkit servers return an array instead of collection 80 | Class clazz = Bukkit.getServer().getClass(); 81 | try { 82 | Method method = clazz.getMethod("getOnlinePlayers"); 83 | Player players[] = (Player[]) method.invoke(Bukkit.getServer()); 84 | 85 | return Arrays.asList(players); 86 | 87 | } catch (NoSuchMethodException ex) { // funny it's Exception here but Error above 88 | ex.printStackTrace(); 89 | } catch (IllegalAccessException ex) { 90 | ex.printStackTrace(); 91 | } catch (InvocationTargetException ex) { 92 | ex.printStackTrace(); 93 | } 94 | } 95 | 96 | return Collections.emptyList(); 97 | } 98 | 99 | public String encodeLocation(Location location) { 100 | double x = webSocketServerThread.blockBridge.toWebLocationEntityX(location); 101 | double y = webSocketServerThread.blockBridge.toWebLocationEntityY(location); 102 | double z = webSocketServerThread.blockBridge.toWebLocationEntityZ(location); 103 | 104 | // yaw is degrees, 0(360)=+z, 180=-z, 90=-x, 270=+x 105 | float yaw = location.getYaw(); 106 | 107 | // pitch is degrees, -90 (upward-facing, +y), or 0 (level), to 90 (downward facing, -y) 108 | float pitch = location.getPitch(); 109 | 110 | // Craft uses radians, and flips it 111 | double rx = -yaw * Math.PI / 180; 112 | double ry = -pitch * Math.PI / 180; 113 | 114 | return x + "," + y + "," + z + "," + rx + "," + ry; 115 | } 116 | 117 | public void notifyMove(int id, String name, Location location) { 118 | if (!seePlayers) { 119 | return; 120 | } 121 | 122 | if (!webSocketServerThread.blockBridge.withinSandboxRange(location)) { 123 | // No position updates for players outside of the sandbox, but if they were previously inside, kill them 124 | if (this.playersInSandbox.contains(id)) { 125 | this.notifyDelete(id); 126 | } 127 | return; 128 | } 129 | 130 | if (!this.playersInSandbox.contains(id)) { 131 | // Transitioned from outside to inside sandbox - allocate 132 | this.notifyAdd(id, name, location); 133 | } 134 | 135 | webSocketServerThread.broadcastLine("P," + id + "," + encodeLocation(location)); 136 | } 137 | 138 | public void notifyAdd(int id, String name, Location initialLocation) { 139 | if (!seePlayers) { 140 | return; 141 | } 142 | 143 | if (!webSocketServerThread.blockBridge.withinSandboxRange(initialLocation)) { 144 | return; 145 | } 146 | this.playersInSandbox.add(id); 147 | 148 | // Craft requires P (position update) before N (name), since it allocates the entity in P... 149 | // even though it is named in N (before that, default name 'player'+id). Therefore we must send P first. 150 | // TODO: change this behavior on client, allowing N to allocate? OTOH, the initial position is important... 151 | this.notifyMove(id, name, initialLocation); 152 | 153 | webSocketServerThread.broadcastLine("N," + id + "," + name); 154 | } 155 | 156 | public void notifyDelete(int id) { 157 | if (!seePlayers) { 158 | return; 159 | } 160 | 161 | if (this.playersInSandbox.contains(id)) { 162 | this.playersInSandbox.remove(id); 163 | // delete this entity 164 | webSocketServerThread.broadcastLine("D," + id); 165 | } 166 | } 167 | 168 | public void notifyChat(String message) { 169 | if (!seeChat) { 170 | return; 171 | } 172 | 173 | webSocketServerThread.broadcastLine("T," + message); 174 | } 175 | 176 | public void clientChat(ChannelHandlerContext ctx, String theirName, String chat) { 177 | if (!allowChatting) { 178 | webSocketServerThread.sendLine(ctx.channel(), "T,Chatting is not allowed"); 179 | return; 180 | } 181 | 182 | String formattedChat = "<" + theirName + "> " + chat; 183 | webSocketServerThread.broadcastLine("T," + formattedChat); 184 | Bukkit.getServer().broadcastMessage(formattedChat); // TODO: only to permission name? 185 | 186 | // TODO: support some server /commands? 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketIndexPageHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 The Netty Project 3 | * 4 | * The Netty Project licenses this file to you under the Apache License, 5 | * version 2.0 (the "License"); you may not use this file except in compliance 6 | * with the License. You may obtain a copy of the License at: 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations 14 | * under the License. 15 | */ 16 | package io.github.satoshinm.WebSandboxMC.ws; 17 | 18 | import io.netty.buffer.ByteBuf; 19 | import io.netty.buffer.Unpooled; 20 | import io.netty.channel.ChannelFuture; 21 | import io.netty.channel.ChannelFutureListener; 22 | import io.netty.channel.ChannelHandlerContext; 23 | import io.netty.channel.SimpleChannelInboundHandler; 24 | import io.netty.handler.codec.http.DefaultFullHttpResponse; 25 | import io.netty.handler.codec.http.FullHttpRequest; 26 | import io.netty.handler.codec.http.FullHttpResponse; 27 | import io.netty.handler.codec.http.HttpHeaderNames; 28 | import io.netty.handler.codec.http.HttpUtil; 29 | import io.netty.util.CharsetUtil; 30 | 31 | import java.io.*; 32 | 33 | import static io.netty.handler.codec.http.HttpMethod.GET; 34 | import static io.netty.handler.codec.http.HttpResponseStatus.*; 35 | import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; 36 | 37 | /** 38 | * Outputs index page content. 39 | */ 40 | public class WebSocketIndexPageHandler extends SimpleChannelInboundHandler { 41 | private final File pluginDataFolder; 42 | 43 | public WebSocketIndexPageHandler(File pluginDataFolder) { 44 | this.pluginDataFolder = pluginDataFolder; 45 | } 46 | 47 | private InputStream getResourceAsStream(String name) { 48 | // If it exists, use files in plugin resource directory - otherwise, embedded resources in our plugin jar 49 | 50 | // TODO: cache to avoid checking each time? 51 | File file = new File(this.pluginDataFolder, name); 52 | if (file.exists()) { 53 | try { 54 | return new FileInputStream(file); 55 | } catch (FileNotFoundException ex) { 56 | // fallthrough 57 | } 58 | } 59 | 60 | return getClass().getResourceAsStream(name); 61 | } 62 | 63 | private void sendTextResource(String prepend, String name, String mimeType, FullHttpRequest req, ChannelHandlerContext ctx) throws IOException { 64 | BufferedReader reader = new BufferedReader(new InputStreamReader((this.getResourceAsStream(name)))); 65 | // TODO: read only once and buffer 66 | String line; 67 | StringBuffer buffer = new StringBuffer(); 68 | if (prepend != null) buffer.append(prepend); 69 | while ((line = reader.readLine()) != null) { 70 | buffer.append(line); 71 | buffer.append('\n'); 72 | } 73 | ByteBuf content = Unpooled.copiedBuffer(buffer, java.nio.charset.Charset.forName("UTF-8")); 74 | 75 | FullHttpResponse res = new DefaultFullHttpResponse(HTTP_1_1, OK, content); 76 | 77 | res.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeType); 78 | HttpUtil.setContentLength(res, content.readableBytes()); 79 | 80 | sendHttpResponse(ctx, req, res); 81 | } 82 | 83 | private void sendBinaryResource(String name, String mimeType, FullHttpRequest req, ChannelHandlerContext ctx) throws IOException { 84 | DataInputStream stream = new DataInputStream(this.getResourceAsStream(name)); 85 | 86 | ByteArrayOutputStream buffer = new ByteArrayOutputStream(); 87 | 88 | int nRead; 89 | byte[] data = new byte[16384]; 90 | 91 | while ((nRead = stream.read(data, 0, data.length)) != -1) { 92 | buffer.write(data, 0, nRead); 93 | } 94 | 95 | buffer.flush(); 96 | 97 | ByteBuf content = Unpooled.copiedBuffer(buffer.toByteArray()); 98 | 99 | FullHttpResponse res = new DefaultFullHttpResponse(HTTP_1_1, OK, content); 100 | 101 | res.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeType); 102 | HttpUtil.setContentLength(res, content.readableBytes()); 103 | 104 | sendHttpResponse(ctx, req, res); 105 | } 106 | 107 | @Override 108 | protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception { 109 | // Handle a bad request. 110 | if (!req.decoderResult().isSuccess()) { 111 | sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, BAD_REQUEST)); 112 | return; 113 | } 114 | 115 | // Allow only GET methods. 116 | if (req.method() != GET) { 117 | sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, FORBIDDEN)); 118 | return; 119 | } 120 | 121 | // Send the index page 122 | if ("/".equals(req.uri()) || "/index.html".equals(req.uri()) || "/craft.html".equals(req.uri())) { 123 | sendTextResource(null,"/craft.html", "text/html; charset=UTF-8", req, ctx); 124 | } else if ("/craft.js".equals(req.uri()) || "/craftw.js".equals(req.uri())) { 125 | String prepend = "window.DEFAULT_ARGV = ['-'];"; // connect back to self 126 | sendTextResource(prepend, req.uri(), "application/javascript; charset=UTF-8", req, ctx); 127 | } else if ("/craft.html.mem".equals(req.uri())) { 128 | sendBinaryResource(req.uri(), "application/octet-stream", req, ctx); 129 | } else if ("/craftw.wasm".equals(req.uri())) { 130 | // craftw = webassembly build 131 | sendBinaryResource(req.uri(), "application/octet-stream", req, ctx); 132 | } else if ("/craft.data".equals(req.uri()) || "/craftw.data".equals(req.uri())) { 133 | // same data file for both asmjs and webassembly 134 | sendBinaryResource("/craft.data", "application/octet-stream", req, ctx); 135 | } else if ("/textures.zip".equals(req.uri())) { 136 | File file = new File(this.pluginDataFolder, "textures.zip"); 137 | if (file.exists()) { 138 | sendBinaryResource(req.uri(), "application/octet-stream", req, ctx); 139 | } else { 140 | System.out.println("request for /textures.zip but does not exist in plugin data folder"); 141 | sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, PRECONDITION_FAILED)); 142 | } 143 | } else { 144 | sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HTTP_1_1, NOT_FOUND)); 145 | } 146 | } 147 | 148 | @Override 149 | public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { 150 | cause.printStackTrace(); 151 | ctx.close(); 152 | } 153 | 154 | private static void sendHttpResponse(ChannelHandlerContext ctx, FullHttpRequest req, FullHttpResponse res) { 155 | // Generate an error page if response getStatus code is not OK (200). 156 | if (res.status().code() != 200) { 157 | ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(), CharsetUtil.UTF_8); 158 | res.content().writeBytes(buf); 159 | buf.release(); 160 | HttpUtil.setContentLength(res, res.content().readableBytes()); 161 | } 162 | 163 | // Send the response and close the connection if necessary. 164 | ChannelFuture f = ctx.channel().writeAndFlush(res); 165 | if (!HttpUtil.isKeepAlive(req) || res.status().code() != 200) { 166 | f.addListener(ChannelFutureListener.CLOSE); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSandboxMC 2 | 3 | Bukkit plugin providing a web-based interface with an interactive WebGL 3D preview or glimpse of your server 4 | 5 | ![Screenshot](screenshot.png) 6 | 7 | **Downloads: [WebSandboxMC at Spigot Resources](https://www.spigotmc.org/resources/websandboxmc.39415/)**, or [GitHub releases](https://github.com/satoshinm/WebSandboxMC/releases/) 8 | 9 | [![CircleCI](https://circleci.com/gh/satoshinm/WebSandboxMC.svg?style=svg)](https://circleci.com/gh/satoshinm/WebSandboxMC) 10 | 11 | ## Features 12 | Currently supports: 13 | 14 | * Exposes a small piece of your server to web users, with configurable location and dimensions 15 | * Web users can place/break blocks, and see server block changes in realtime 16 | * Web users can send/receive chat, and other users can see their chat messages 17 | * Web users can see players on your server and other web users move and rotate 18 | * Sheep are spawned with custom names and track the web users movements 19 | 20 | TODO: missing features 21 | 22 | ## Compilation 23 | * Install [Maven 3](http://maven.apache.org/download.html) 24 | * Check out this repository 25 | * Check out and build [NetCraft](https://github.com/satoshinm/NetCraft) using emscripten, when it completes copy the build output into resources: 26 | 27 | ```sh 28 | cp ../NetCraft/release-build-js/craft.* src/main/resources/` 29 | cp ../NetCraft/wasm-build/craftw.* src/main/resources/` 30 | perl -pe 's(\Q{{{ SCRIPT }}}\E)("")e' -i src/main/resources/craft.html 31 | ``` 32 | 33 | * Build the WebSandboxMC plugin: `mvn package` 34 | 35 | ## Usage 36 | 1. Copy target/WebSandboxMC.jar to the `plugins` folder of your Bukkit-compatible server (see below) 37 | 2. Visit http://localhost:4081/ in a modern browser (requires WebGL, Pointer Lock, WebSockets) 38 | 3. Play the game 39 | 40 | ## Configuration 41 | 42 | After an initial run, `plugins/WebSandboxMC/config.yml` should be populated with configuration defaults. 43 | The settings are as follows: 44 | 45 | ### http 46 | Configures the HTTP and WebSocket server: 47 | 48 | * `port` (4081): TCP port for the HTTP server to listen on 49 | * `publicURL` (http://localhost:4081/) - URL for publicly accessing this server, sent to clients when running the `/websandbox auth` command 50 | * `takeover` (false): advanced experimental option to reuse the server port from Bukkit (ignoring `port`) before startup, allowing this plugin to be used on hosts where only one port is allowed 51 | * `unbind_method` ('console.getServerConnection.b'): if `takeover` enabled, this method is called on `Bukkit.getServer()`, may need to change depending on your Bukkit server implementation 52 | 53 | ### mc 54 | Configures what part of your world to expose: 55 | 56 | * `debug` (false): if true, enables vast amounts of additional logging with FINEST log level 57 | * `netty_log_info` (false): if true, enables Netty connection logging at INFO level instead of DEBUG 58 | * `use_permissions` (false): if false, `/websandbox` command requires op; if true, checks for `websandbox.command.`+subcommand permission node 59 | * `world` (""): name of world for web clients to spawn in, or an empty string to use the first available 60 | * `x_center` (0): specifies the center of the world from the web client's perspective, X coordinate 61 | * `y_center` (75): " ", Y coordinate 62 | * `z_center` (0): " ", Z coordinate 63 | * If x/y/z center are all 0, then the world's spawn location is used instead 64 | * `radius` (16): range out of the center to expose in each direction (cube), setting too high will slow down web client loading 65 | * `clickable_links` (true): send clickable links in chat commands from `/websandbox auth` if true, or as plain text if false 66 | * `clickable_links_tellraw` (false): use the `/tellraw` command to send richly formatted messages if true, or use the TextComponents API if false, change this if you get a formatting error with `/websandbox auth` 67 | * `entity` ("Sheep"): name of entity class to spawn on server for web users, set to "" to disable 68 | * `entity_custom_names` (true): add web player names to the spawned entity's nametag if true 69 | * `entity_disable_gravity` (true): disable gravity for the spawned entities if true 70 | * `entity_disable_ai` (true): disable AI for the spawned living entities if true, otherwise they may move on their own 71 | * `entity_move_sandbox` (true): constrain the web player entity's movement to within the sandbox, otherwise they can go anywhere 72 | * `entity_die_disconnect` (false): disconnect the web player when their entity dies, otherwise they remain connected invisibly 73 | 74 | ### nc 75 | Configures the NetCraft web client: 76 | 77 | * `y_offset` (20): height to shift the web client blocks upwards, to distinguish from the pre-generated landscape 78 | * `allow_anonymous` (true): allow web users to connect without logging in, otherwise a player must first run `/websandbox auth` and click the link 79 | * `check_ip_bans` (true): ban web clients by IP if they are in the server IP ban list, from e.g. the `/ban-ip` command 80 | * `allow_break_place_blocks` (true): allow web users to break/place blocks, set to false for view-only (see also `allow_signs`) 81 | * `unbreakable_blocks` (`BEDROCK`): list of block types to deny the client from breaking or placing 82 | * `allow_signs` (true): allow web users to place signs (by typing backquote followed by the text) 83 | * `allow_chatting` (true): allow web users to send chat messages to the server 84 | * `see_chat` (true): allow web users to receive chat messages from the server 85 | * `see_players` (true): allow web users to see other player positions 86 | * `see_time` (true): sync server time to web client time if true, if false then fixed at noon 87 | * `creative_mode` (true): if true, the web client is set to creative mode by default, else survival mode (warning: survival mode is incomplete and experiemntal) 88 | * `blocks_to_web_override`: map of Bukkit material names to NetCraft web client IDs -- you can add additional block types here if they don't show up correctly 89 | * This overrides the built-in map, and by default is empty (`blocks_to_web` pre-1.4.2 is no longer used). 90 | * The special material name "missing" is used for unmapped blocks, interesting values: 91 | * 16 (default): clouds, a solid white block you can walk through, useful as a placeholder so you know to edit spawn and remove or translate it 92 | * 0: air, for if you want missing/unknown/unsupported blocks to be invisible to the web client 93 | * `warn_missing_blocks_to_web` (true): log the type and location of untranslated blocks so you can fix them, set to false (and `blocks_to_web_override` "missing" to 0) if you don't care 94 | 95 | Newer versions of NetCraft can be installed without upgrading the plugin, or the main page customized, 96 | by placing the saving files in the plugin's data directory: craft.html (main page), craft.js, craft.html.mem. 97 | If not given, WebSandboxMC's embedded version of NetCraft will be served up instead. 98 | 99 | If the plugin folder contains a file named textures.zip, then it will be sent as a custom texture pack 100 | to the client. This archive must contain a `terrain.png` with a texture atlas. For details on 101 | texture pack compatibility, see [NetCraft#textures](https://github.com/satoshinm/NetCraft#textures). 102 | 103 | ## Commands 104 | 105 | * `/websandbox` or `/websandbox help`: show help 106 | * `/websandbox list [verbose]`: list all web users connected 107 | * `/websandbox tp []`: teleport to given web username, or web spawn location 108 | * `/websandbox kick `: disconnect given web username 109 | * `/websandbox clear`: remove stale web player entities which can occur if the server is abnormally terminated 110 | * `/websandbox auth []`: generates an a web link to allow the player to authenticate over the web as themselves instead of anonymously 111 | 112 | All commands except help and auth require op, or a `websandbox.command.` permission node. 113 | 114 | ## Compatibility 115 | 116 | WebSandboxMC uses the [Bukkit API from Spigot](https://hub.spigotmc.org/javadocs/bukkit/) with the aim of maximizing 117 | server compatibility. Known compatible server software: 118 | 119 | * [Glowstone](https://www.glowstone.net): for a fully open source end-to-end gameplay experience 120 | * [SpigotMC](https://www.spigotmc.org) 121 | 122 | WebSandboxMC 2.x is compatible with 1.17 (and possibly newer) down to 1.14 API version. 123 | 124 | WebSandboxMC 1.x is compatible down to 1.7, but will use "legacy materials" on API versions newer than 1.12. 125 | 126 | ## License 127 | 128 | MIT 129 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerThread.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 The Netty Project 3 | * 4 | * The Netty Project licenses this file to you under the Apache License, 5 | * version 2.0 (the "License"); you may not use this file except in compliance 6 | * with the License. You may obtain a copy of the License at: 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations 14 | * under the License. 15 | */ 16 | package io.github.satoshinm.WebSandboxMC.ws; 17 | 18 | import io.github.satoshinm.WebSandboxMC.Settings; 19 | import io.github.satoshinm.WebSandboxMC.bridge.BlockBridge; 20 | import io.github.satoshinm.WebSandboxMC.bridge.WebPlayerBridge; 21 | import io.github.satoshinm.WebSandboxMC.bridge.PlayersBridge; 22 | import io.netty.bootstrap.ServerBootstrap; 23 | import io.netty.buffer.ByteBuf; 24 | import io.netty.buffer.Unpooled; 25 | import io.netty.channel.Channel; 26 | import io.netty.channel.ChannelHandlerContext; 27 | import io.netty.channel.ChannelId; 28 | import io.netty.channel.EventLoopGroup; 29 | import io.netty.channel.group.ChannelGroup; 30 | import io.netty.channel.group.DefaultChannelGroup; 31 | import io.netty.channel.nio.NioEventLoopGroup; 32 | import io.netty.channel.socket.nio.NioServerSocketChannel; 33 | import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; 34 | import io.netty.handler.logging.LogLevel; 35 | import io.netty.handler.logging.LoggingHandler; 36 | import io.netty.handler.ssl.SslContext; 37 | import io.netty.handler.ssl.SslContextBuilder; 38 | import io.netty.handler.ssl.util.SelfSignedCertificate; 39 | import io.netty.util.concurrent.ImmediateEventExecutor; 40 | 41 | import java.net.InetSocketAddress; 42 | import java.util.logging.Level; 43 | 44 | /** 45 | * A HTTP server which serves Web Socket requests at: 46 | * 47 | * http://localhost:8080/websocket 48 | * 49 | * Open your browser at http://localhost:8080/, then the demo page will be loaded 50 | * and a Web Socket connection will be made automatically. 51 | * 52 | * This server illustrates support for the different web socket specification versions and will work with: 53 | * 54 | *
    55 | *
  • Safari 5+ (draft-ietf-hybi-thewebsocketprotocol-00) 56 | *
  • Chrome 6-13 (draft-ietf-hybi-thewebsocketprotocol-00) 57 | *
  • Chrome 14+ (draft-ietf-hybi-thewebsocketprotocol-10) 58 | *
  • Chrome 16+ (RFC 6455 aka draft-ietf-hybi-thewebsocketprotocol-17) 59 | *
  • Firefox 7+ (draft-ietf-hybi-thewebsocketprotocol-10) 60 | *
  • Firefox 11+ (RFC 6455 aka draft-ietf-hybi-thewebsocketprotocol-17) 61 | *
62 | */ 63 | public final class WebSocketServerThread extends Thread { 64 | 65 | private int PORT; 66 | private boolean SSL; 67 | 68 | private ChannelGroup allUsersGroup; 69 | 70 | public BlockBridge blockBridge; 71 | public PlayersBridge playersBridge; 72 | public WebPlayerBridge webPlayerBridge; 73 | private Settings settings; 74 | 75 | public WebSocketServerThread(Settings settings) { 76 | this.PORT = settings.httpPort; 77 | this.SSL = false; // TODO: support ssl? 78 | 79 | this.blockBridge = null; 80 | this.playersBridge = null; 81 | this.webPlayerBridge = null; 82 | 83 | this.allUsersGroup = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE); 84 | 85 | this.settings = settings; 86 | } 87 | 88 | public void log(Level level, String message) { 89 | settings.log(level, message); 90 | } 91 | 92 | public void scheduleSyncTask(Runnable runnable) { 93 | settings.scheduleSyncTask(runnable); 94 | } 95 | 96 | @Override 97 | public void run() { 98 | try { 99 | // Configure SSL. 100 | final SslContext sslCtx; 101 | if (SSL) { 102 | SelfSignedCertificate ssc = new SelfSignedCertificate(); 103 | sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build(); 104 | } else { 105 | sslCtx = null; 106 | } 107 | 108 | EventLoopGroup bossGroup = new NioEventLoopGroup(1); 109 | EventLoopGroup workerGroup = new NioEventLoopGroup(); 110 | try { 111 | ServerBootstrap b = new ServerBootstrap(); 112 | b.group(bossGroup, workerGroup) 113 | .channel(NioServerSocketChannel.class) 114 | .handler(settings.nettyLogInfo ? new LoggingHandler(LogLevel.INFO) : new LoggingHandler()) 115 | .childHandler(new WebSocketServerInitializer(sslCtx, this, 116 | settings.pluginDataFolder, settings.checkIPBans)); 117 | 118 | Channel ch = b.bind(PORT).sync().channel(); 119 | 120 | log(Level.INFO, "Open your web browser and navigate to " + 121 | (SSL ? "https" : "http") + "://127.0.0.1:" + PORT + "/" + 122 | " or " + settings.publicURL); 123 | 124 | ch.closeFuture().sync(); 125 | } catch (InterruptedException ex) { 126 | // plugin is shutting down - let it interrupt quietly 127 | } finally { 128 | bossGroup.shutdownGracefully(); 129 | workerGroup.shutdownGracefully(); 130 | } 131 | } catch (Exception ex) { 132 | ex.printStackTrace(); 133 | } 134 | } 135 | 136 | public void sendLine(Channel channel, String message) { 137 | channel.writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer((message + "\n").getBytes()))); 138 | } 139 | 140 | public void sendBinary(Channel channel, ByteBuf data) { 141 | channel.writeAndFlush(new BinaryWebSocketFrame(data)); 142 | } 143 | 144 | public void broadcastLine(String message) { 145 | allUsersGroup.writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer((message + "\n").getBytes()))); 146 | } 147 | 148 | public void broadcastLineExcept(ChannelId excludeChannelId, String message) { 149 | for (Channel channel: allUsersGroup) { 150 | if (channel.id().equals(excludeChannelId)) { 151 | continue; 152 | } 153 | 154 | channel.writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer((message + "\n").getBytes()))); 155 | } 156 | } 157 | 158 | public void handleNewClient(ChannelHandlerContext ctx, String username, String token) { 159 | Channel channel = ctx.channel(); 160 | 161 | if (!webPlayerBridge.newPlayer(channel, username, token)) { 162 | channel.close(); 163 | return; 164 | } 165 | 166 | allUsersGroup.add(channel); 167 | 168 | 169 | /* Send initial server messages on client connect here, example from Python server for comparison: 170 | 171 | U,1,0,0,0,0,0 172 | E,1491627331.01,600 173 | T,Welcome to Craft! 174 | T,Type "/help" for a list of commands. 175 | N,1,guest1 176 | */ 177 | sendLine(channel, "B,0,0,0,30,0,1"); // floating grass block at (0,30,0) in chunk (0,0) 178 | sendLine(channel, "K,0,0,0"); // update chunk key (0,0) to 0 179 | sendLine(channel, "R,0,0"); // refresh chunk (0,0) 180 | 181 | blockBridge.sendWorld(channel); 182 | playersBridge.sendPlayers(channel); 183 | } 184 | 185 | public String getRemoteIP(Channel channel) { 186 | return ((InetSocketAddress) channel.remoteAddress()).getHostString(); 187 | // TODO: respect X-Forwarded-For optionally, https://github.com/satoshinm/WebSandboxMC/issues/87 188 | } 189 | 190 | public int getRemotePort(Channel channel) { 191 | return ((InetSocketAddress) channel.remoteAddress()).getPort(); 192 | } 193 | 194 | public String getRemoteIPandPort(Channel channel) { 195 | return getRemoteIP(channel) + ":" + getRemotePort(channel); 196 | } 197 | 198 | // Handle a command from the client 199 | public void handle(String string, ChannelHandlerContext ctx) { 200 | if (string.startsWith("A,")) { 201 | String[] array = string.trim().split(","); 202 | String username = ""; 203 | String token = ""; 204 | if (array.length == 3) { 205 | username = array[1]; 206 | token = array[2]; 207 | } 208 | handleNewClient(ctx, username, token); 209 | return; 210 | } 211 | 212 | if (!allUsersGroup.contains(ctx.channel())) { 213 | // Commands below this point require a successfully logged-in user 214 | this.log(Level.FINEST, "Client tried to send command when not authenticated: "+string+" from "+ctx); 215 | return; 216 | } 217 | 218 | if (string.startsWith("B,")) { 219 | this.log(Level.FINEST, "client block update: "+string); 220 | String[] array = string.trim().split(","); 221 | if (array.length != 5) { 222 | throw new RuntimeException("malformed block update B, command from client: "+string); 223 | } 224 | int x = Integer.parseInt(array[1]); 225 | int y = Integer.parseInt(array[2]); 226 | int z = Integer.parseInt(array[3]); 227 | int type = Integer.parseInt(array[4]); 228 | 229 | blockBridge.clientBlockUpdate(ctx, x, y, z, type); 230 | } else if (string.startsWith("T,")) { 231 | String chat = string.substring(2).trim(); 232 | String theirName = this.webPlayerBridge.channelId2name.get(ctx.channel().id()); 233 | 234 | playersBridge.clientChat(ctx, theirName, chat); 235 | } else if (string.startsWith("P,")) { 236 | String[] array = string.trim().split(","); 237 | if (array.length != 6) { 238 | throw new RuntimeException("malformed client position update P: "+string); 239 | } 240 | double x = Double.parseDouble(array[1]); 241 | double y = Double.parseDouble(array[2]); 242 | double z = Double.parseDouble(array[3]); 243 | double rx = Double.parseDouble(array[4]); 244 | double ry = Double.parseDouble(array[5]); 245 | 246 | webPlayerBridge.clientMoved(ctx.channel(), x, y, z, rx, ry); 247 | } else if (string.startsWith("S,")) { 248 | String[] array = string.trim().split(",", 6); 249 | if (array.length != 6) { 250 | throw new RuntimeException("malformed sign text update S: "+string); 251 | } 252 | 253 | int x = Integer.parseInt(array[1]); 254 | int y = Integer.parseInt(array[2]); 255 | int z = Integer.parseInt(array[3]); 256 | int face = Integer.parseInt(array[4]); 257 | String text = array[5]; 258 | 259 | this.log(Level.FINEST, "new sign: "+x+","+y+","+z+" face="+face+", text="+text); 260 | 261 | this.blockBridge.clientNewSign(ctx, x, y, z, face, text); 262 | } 263 | 264 | // TODO: handle more client messages 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bridge/WebPlayerBridge.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bridge; 2 | 3 | import io.github.satoshinm.WebSandboxMC.Settings; 4 | import io.github.satoshinm.WebSandboxMC.bukkit.ClickableLinks; 5 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 6 | import io.netty.channel.Channel; 7 | import io.netty.channel.ChannelId; 8 | import org.bukkit.Location; 9 | import org.bukkit.command.CommandSender; 10 | import org.bukkit.entity.Entity; 11 | import org.bukkit.entity.LivingEntity; 12 | import org.bukkit.entity.Player; 13 | import org.bukkit.entity.Sheep; 14 | import org.bukkit.event.entity.EntityDamageEvent; 15 | 16 | import java.math.BigInteger; 17 | import java.security.SecureRandom; 18 | import java.util.Collection; 19 | import java.util.HashMap; 20 | import java.util.Map; 21 | import java.util.logging.Level; 22 | 23 | /** 24 | * Bridges the web client players 25 | */ 26 | public class WebPlayerBridge { 27 | 28 | private final WebSocketServerThread webSocketServerThread; 29 | 30 | private int lastPlayerID; 31 | public Map channelId2name; 32 | public Map channelId2Entity; 33 | public Map entityId2Username; 34 | public Map name2channel; 35 | 36 | private Map playerAuthKeys = new HashMap(); 37 | private boolean clickableLinks; 38 | private boolean clickableLinksTellraw; 39 | private String publicURL; 40 | 41 | private boolean allowAnonymous; 42 | private boolean setCustomNames; 43 | private boolean disableGravity; 44 | private boolean disableAI; 45 | private Class entityClass; 46 | private boolean constrainToSandbox; 47 | private boolean dieDisconnect; 48 | 49 | public WebPlayerBridge(WebSocketServerThread webSocketServerThread, Settings settings) { 50 | this.webSocketServerThread = webSocketServerThread; 51 | this.setCustomNames = settings.setCustomNames; 52 | this.disableGravity = settings.disableGravity; 53 | this.disableAI = settings.disableAI; 54 | 55 | if (settings.entityClassName == null || "".equals(settings.entityClassName)) { 56 | this.entityClass = null; 57 | } else { 58 | try { 59 | this.entityClass = Class.forName("org.bukkit.entity." + settings.entityClassName); 60 | } catch (ClassNotFoundException ex) { 61 | ex.printStackTrace(); 62 | 63 | // HumanEntity.class fails on Glowstone with https://gist.github.com/satoshinm/ebc87cdf1d782ba91b893fe24cd8ffd2 64 | // so use sheep instead for now. TODO: spawn ala GlowNPC: https://github.com/satoshinm/WebSandboxMC/issues/13 65 | webSocketServerThread.log(Level.WARNING, "No such entity class " + settings.entityClassName + ", falling back to Sheep"); 66 | this.entityClass = Sheep.class; 67 | } 68 | } 69 | 70 | this.constrainToSandbox = settings.entityMoveSandbox; 71 | this.dieDisconnect = settings.entityDieDisconnect; 72 | 73 | this.clickableLinks = settings.clickableLinks; 74 | this.clickableLinksTellraw = settings.clickableLinksTellraw; 75 | this.publicURL = settings.publicURL; 76 | 77 | this.allowAnonymous = settings.allowAnonymous; 78 | this.lastPlayerID = 0; 79 | this.channelId2name = new HashMap(); 80 | this.channelId2Entity = new HashMap(); 81 | this.entityId2Username = new HashMap(); 82 | this.name2channel = new HashMap(); 83 | } 84 | 85 | public boolean newPlayer(final Channel channel, String proposedUsername, String token) { 86 | String theirName; 87 | boolean authenticated = false; 88 | if (validateClientAuthKey(proposedUsername, token)) { 89 | theirName = proposedUsername; 90 | authenticated = true; 91 | // TODO: more features when logging in as an authenticated user: move to their last spawn? 92 | } else { 93 | if (!proposedUsername.equals("")) { // blank = anonymous 94 | webSocketServerThread.sendLine(channel, "T,Failed to login as "+proposedUsername); 95 | } 96 | 97 | if (!allowAnonymous) { 98 | webSocketServerThread.sendLine(channel,"T,This server requires authentication."); 99 | return false; 100 | } 101 | 102 | int theirID = ++this.lastPlayerID; 103 | theirName = "webguest" + theirID; 104 | } 105 | String ip = webSocketServerThread.getRemoteIPandPort(channel); 106 | webSocketServerThread.log(Level.INFO, "New web client joined: " + theirName + 107 | (authenticated ? " (authenticated)" : " (anonymous)") + " from " + ip); 108 | 109 | this.channelId2name.put(channel.id(), theirName); 110 | this.name2channel.put(theirName, channel); 111 | 112 | webSocketServerThread.sendLine(channel, "T,Welcome to WebSandboxMC, "+theirName+"!"); 113 | 114 | if (this.entityClass != null) { 115 | // Spawn an entity in the web user's place 116 | Location location = webSocketServerThread.blockBridge.spawnLocation; 117 | Entity entity = webSocketServerThread.blockBridge.world.spawn(location, (Class) this.entityClass); 118 | if (setCustomNames) { 119 | entity.setCustomName(theirName); // name tag 120 | entity.setCustomNameVisible(true); 121 | } 122 | if (disableGravity) { 123 | entity.setGravity(false); // allow flying 124 | } 125 | if (disableAI) { 126 | if (entity instanceof LivingEntity) { 127 | LivingEntity livingEntity = (LivingEntity) entity; 128 | livingEntity.setAI(false); 129 | } 130 | } 131 | channelId2Entity.put(channel.id(), entity); 132 | entityId2Username.put(entity.getEntityId(), theirName); 133 | 134 | // Notify other web clients (except this one) of this new user 135 | webSocketServerThread.broadcastLineExcept(channel.id(), "P," + entity.getEntityId() + "," + webSocketServerThread.playersBridge.encodeLocation(location)); 136 | webSocketServerThread.broadcastLineExcept(channel.id(), "N," + entity.getEntityId() + "," + theirName); 137 | } 138 | 139 | // TODO: should this go to Bukkit chat, too/instead? make configurable? 140 | webSocketServerThread.broadcastLine("T," + theirName + " has joined."); 141 | return true; 142 | } 143 | 144 | public void clearStaleEntities(CommandSender sender) { 145 | if (this.entityClass == null) { 146 | sender.sendMessage("Nothing to clear, no entity class set"); 147 | return; 148 | } 149 | 150 | int removed = 0; 151 | int found = 0; 152 | 153 | // Get all entities within the web radius - note: server implementations may restrict these bounds 154 | double r = webSocketServerThread.blockBridge.radius; 155 | Collection entities = webSocketServerThread.blockBridge.world.getNearbyEntities( 156 | webSocketServerThread.blockBridge.spawnLocation, r, r, r); 157 | for (Entity entity : entities) { 158 | ++found; 159 | 160 | webSocketServerThread.log(Level.INFO, "looking at entity "+entity); 161 | if (this.entityClass.isInstance(entity)) { 162 | 163 | if (entityId2Username.containsKey(entity.getEntityId())) { 164 | webSocketServerThread.log(Level.INFO, "ignored, in use: "+entity+ 165 | " by "+entityId2Username.get(entity.getEntityId())); 166 | continue; 167 | } 168 | 169 | // Skip entities that don't match our expected properties 170 | 171 | if (this.setCustomNames && entity.getCustomName() == null) { 172 | webSocketServerThread.log(Level.INFO, "ignored, missing custom name: "+entity); 173 | continue; 174 | } 175 | 176 | if (this.disableGravity && entity.hasGravity()) { 177 | webSocketServerThread.log(Level.INFO, "ignored, missing no gravity: "+entity); 178 | continue; 179 | } 180 | 181 | /* Glowstone seems to not persist setAI()? 182 | if (entity instanceof LivingEntity) { 183 | LivingEntity livingEntity = (LivingEntity) entity; 184 | if (this.disableAI && livingEntity.hasAI()) { 185 | webSocketServerThread.log(Level.WARNING, "ignored, missing no AI: "+entity); 186 | continue; 187 | } 188 | } 189 | */ 190 | 191 | webSocketServerThread.log(Level.INFO, "removing "+entity); 192 | 193 | sender.sendMessage("Removing " + entity); 194 | entity.remove(); 195 | ++removed; 196 | } 197 | } 198 | sender.sendMessage("Removed "+removed+" of "+found+" entities"); 199 | } 200 | 201 | public void clientMoved(final Channel channel, final double x, final double y, final double z, final double rx, final double ry) { 202 | if (this.entityClass == null) { 203 | // No bukkit entity, no web-bsaed entity for other players either TODO: synthesize a placeholder entity id for web-to-web only? 204 | return; 205 | } 206 | 207 | final Entity entity = this.channelId2Entity.get(channel.id()); 208 | 209 | Location location = webSocketServerThread.blockBridge.toBukkitPlayerLocation(x, y, z); 210 | 211 | if (constrainToSandbox && !webSocketServerThread.blockBridge.withinSandboxRange(location)) { 212 | webSocketServerThread.log(Level.FINEST, "client tried to move outside of sandbox: "+location); 213 | return; 214 | } 215 | 216 | // Opposite of PlayerBridge encodeLocation - given negated radians, convert to degrees 217 | location.setYaw((float)(-rx * 180 / Math.PI)); 218 | location.setPitch((float)(-ry * 180 / Math.PI)); 219 | 220 | // Move the surrogate entity to represent where the web player is 221 | entity.teleport(location); 222 | 223 | // Notify other web clients (except this one) they moved 224 | webSocketServerThread.broadcastLineExcept(channel.id(), "P,"+entity.getEntityId()+","+webSocketServerThread.playersBridge.encodeLocation(location)); 225 | } 226 | 227 | public void clientDisconnected(Channel channel) { 228 | String name = webSocketServerThread.webPlayerBridge.channelId2name.get(channel.id()); 229 | 230 | if (name == null) { 231 | // TODO: Why are some channels activated and inactivated without fully logging in? Either way, ignore. 232 | return; 233 | } 234 | 235 | channelId2name.remove(channel.id()); 236 | 237 | webSocketServerThread.log(Level.FINEST, "web client disconnected: " + name); 238 | // TODO: should this go to Bukkit chat, too/instead? make configurable? 239 | webSocketServerThread.broadcastLine("T," + name + " has disconnected."); 240 | 241 | Entity entity = channelId2Entity.get(channel.id()); 242 | if (entity != null) { 243 | webSocketServerThread.broadcastLineExcept(channel.id(), "D,"+entity.getEntityId()); 244 | 245 | channelId2Entity.remove(channel.id()); 246 | entityId2Username.remove(entity.getEntityId()); 247 | 248 | entity.remove(); 249 | } 250 | 251 | name2channel.remove(name); 252 | } 253 | 254 | public void deleteAllEntities() { 255 | for (Entity entity: channelId2Entity.values()) { 256 | entity.remove(); 257 | } 258 | } 259 | 260 | public void notifyDied(String username, EntityDamageEvent.DamageCause cause) { 261 | webSocketServerThread.log(Level.INFO, "web user "+username+"'s entity died from "+cause); 262 | 263 | Channel channel = name2channel.get(username); 264 | if (channel != null) { 265 | String message = "T,You died from "+(cause == null ? "unknown causes" : cause); 266 | 267 | if (!dieDisconnect) { 268 | message += ", but remain connected to the server as a ghost"; 269 | } 270 | 271 | webSocketServerThread.sendLine(channel, message); 272 | 273 | if (dieDisconnect) { 274 | channel.close(); 275 | clientDisconnected(channel); 276 | } 277 | } 278 | } 279 | 280 | public void notifySheared(String username, String playerName) { 281 | Channel channel = name2channel.get(username); 282 | 283 | webSocketServerThread.log(Level.INFO, "web user " + username + " sheared by " + playerName); 284 | 285 | if (channel != null) { 286 | webSocketServerThread.sendLine(channel, "T,You were sheared by " + playerName); 287 | } 288 | } 289 | 290 | private final SecureRandom random = new SecureRandom(); 291 | 292 | public void newClientAuthKey(String username, CommandSender sender) { 293 | String token = new BigInteger(130, random).toString(32); 294 | 295 | playerAuthKeys.put(username, token); 296 | // TODO: persist to disk 297 | 298 | 299 | String url = publicURL + "#++" + username + "+" + token; 300 | 301 | if (clickableLinks && sender instanceof Player) { 302 | ClickableLinks.sendLink((Player) sender, url, clickableLinksTellraw); 303 | } else { 304 | sender.sendMessage("Visit this URL to login: " + url); 305 | } 306 | } 307 | 308 | private boolean validateClientAuthKey(String username, String token) { 309 | String expected = playerAuthKeys.get(username); 310 | if (expected == null) return false; 311 | return expected.equals(token); 312 | // TODO: load from disk 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/main/java/io/github/satoshinm/WebSandboxMC/bridge/BlockBridge.java: -------------------------------------------------------------------------------- 1 | package io.github.satoshinm.WebSandboxMC.bridge; 2 | 3 | import io.github.satoshinm.WebSandboxMC.Settings; 4 | import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; 5 | import io.netty.buffer.*; 6 | import io.netty.channel.Channel; 7 | import io.netty.channel.ChannelHandlerContext; 8 | import org.bukkit.*; 9 | import org.bukkit.block.Block; 10 | import org.bukkit.block.BlockFace; 11 | import org.bukkit.block.BlockState; 12 | import org.bukkit.block.Sign; 13 | import org.bukkit.block.data.BlockData; 14 | import org.bukkit.block.data.Directional; 15 | import org.bukkit.block.data.Lightable; 16 | 17 | import java.io.ByteArrayOutputStream; 18 | import java.io.IOException; 19 | import java.util.*; 20 | import java.util.logging.Level; 21 | import java.util.zip.DeflaterOutputStream; 22 | 23 | /** 24 | * Bridges blocks in the world, translates between coordinate systems 25 | */ 26 | public class BlockBridge { 27 | 28 | public WebSocketServerThread webSocketServerThread; 29 | private final int x_center, y_center, z_center, y_offset; 30 | public final int radius; 31 | public final World world; 32 | public Location spawnLocation; 33 | private boolean allowBreakPlaceBlocks; 34 | private boolean allowSigns; 35 | private boolean seeTime; 36 | private Map blocksToWeb; 37 | private int blocksToWebMissing; // unknown/unsupported becomes cloud, if key missing 38 | private boolean warnMissing; 39 | private List unbreakableBlocks; 40 | private String textureURL; 41 | private boolean creativeMode; 42 | 43 | public BlockBridge(WebSocketServerThread webSocketServerThread, Settings settings) { 44 | this.webSocketServerThread = webSocketServerThread; 45 | 46 | this.radius = settings.radius; 47 | 48 | this.y_offset = settings.y_offset; 49 | 50 | if (settings.world == null || "".equals(settings.world)) { 51 | this.world = Bukkit.getWorlds().get(0); 52 | } else { 53 | this.world = Bukkit.getWorld(settings.world); 54 | } 55 | if (this.world == null) { 56 | throw new IllegalArgumentException("World not found: " + settings.world); 57 | } 58 | 59 | if (settings.x_center == 0 && settings.y_center == 0 && settings.z_center == 0) { 60 | Location spawn = this.world.getSpawnLocation(); 61 | this.x_center = spawn.getBlockX(); 62 | this.y_center = spawn.getBlockY(); 63 | this.z_center = spawn.getBlockZ(); 64 | } else { 65 | this.x_center = settings.x_center; 66 | this.y_center = settings.y_center; 67 | this.z_center = settings.z_center; 68 | } 69 | 70 | // TODO: configurable spawn within range of sandbox, right now, it is the center of the sandbox 71 | this.spawnLocation = new Location(this.world, this.x_center, this.y_center, this.z_center); 72 | 73 | this.allowBreakPlaceBlocks = settings.allowBreakPlaceBlocks; 74 | this.allowSigns = settings.allowSigns; 75 | this.seeTime = settings.seeTime; 76 | 77 | this.blocksToWeb = new HashMap(); 78 | this.blocksToWebMissing = 16; // unknown/unsupported becomes cloud 79 | 80 | // Overrides from config, if any 81 | for (String materialString : settings.blocksToWebOverride.keySet()) { 82 | Object object = settings.blocksToWebOverride.get(materialString); 83 | 84 | int n = 0; 85 | if (object instanceof String) { 86 | n = Integer.parseInt((String) object); 87 | } else if (object instanceof Integer) { 88 | n = (Integer) object; 89 | } else { 90 | webSocketServerThread.log(Level.WARNING, "blocks_to_web_override invalid integer ignored: "+n+", in "+object); 91 | continue; 92 | } 93 | 94 | 95 | Material material = Material.getMaterial(materialString); 96 | if (materialString.equals("missing")) { 97 | this.blocksToWebMissing = n; 98 | this.webSocketServerThread.log(Level.FINEST, "blocks_to_web_override missing value to set to: "+n); 99 | } else { 100 | if (material == null) { 101 | webSocketServerThread.log(Level.WARNING, "blocks_to_web_override invalid material ignored: " + materialString); 102 | continue; 103 | } 104 | 105 | this.blocksToWeb.put(material, n); 106 | this.webSocketServerThread.log(Level.FINEST, "blocks_to_web_override: " + material + " = " + n); 107 | } 108 | } 109 | 110 | this.warnMissing = settings.warnMissing; 111 | 112 | this.unbreakableBlocks = new ArrayList(); 113 | for (String materialString : settings.unbreakableBlocks) { 114 | Material material = Material.getMaterial(materialString); 115 | if (material == null) { 116 | webSocketServerThread.log(Level.WARNING, "unbreakable_blocks invalid material ignored: " + materialString); 117 | continue; 118 | } 119 | this.unbreakableBlocks.add(material); 120 | } 121 | 122 | this.textureURL = settings.textureURL; 123 | this.creativeMode = settings.creativeMode; 124 | } 125 | 126 | private final ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT; 127 | 128 | // Send the client the initial section of the world when they join 129 | public void sendWorld(final Channel channel) { 130 | if (textureURL != null) { 131 | webSocketServerThread.sendLine(channel, "t," + textureURL); 132 | } 133 | 134 | if (creativeMode) { 135 | webSocketServerThread.sendLine(channel, "m,1"); 136 | } else { 137 | webSocketServerThread.sendLine(channel, "m,0"); 138 | } 139 | 140 | String name = webSocketServerThread.webPlayerBridge.channelId2name.get(channel.id()); 141 | webSocketServerThread.sendLine(channel, "u," + name); 142 | 143 | int day_length = 60 * 20; // 20 minutes 144 | 145 | if (seeTime) { 146 | double fraction = world.getTime() / 1000.0 / 24.0; // 0-1 147 | double elapsed = (fraction + 6.0 / 24) * day_length; 148 | webSocketServerThread.sendLine(channel, "E," + elapsed + "," + day_length); 149 | // TODO: listen for server time change and resend E, command 150 | } else { 151 | webSocketServerThread.sendLine(channel, "E,0,0"); 152 | } 153 | 154 | // Send a multi-block update message announcement that a binary chunk is coming 155 | /* 156 | int startx = -radius; 157 | int starty = y_offset; 158 | int startz = -radius; 159 | int endx = radius - 1; 160 | int endy = radius * 2 - 1 + y_offset; 161 | int endz = radius - 1; 162 | */ 163 | int startx = 0; 164 | int starty = y_offset; 165 | int startz = 0; 166 | int endx = radius * 2 - 1; 167 | int endy = radius * 2 - 1 + y_offset; 168 | int endz = radius * 2 - 1; 169 | 170 | webSocketServerThread.sendLine(channel, "b," + startx + "," + starty + "," + startz + "," + endx + "," + endy + "," + endz); 171 | 172 | ByteBuf data = allocator.buffer( (radius*2) * (radius*2) * (radius*2) * 2); 173 | 174 | boolean thereIsAWorld = false; 175 | LinkedList blockDataUpdates = new LinkedList(); 176 | int offset = 0; 177 | // Gather block data for multiblock update compression 178 | for (int i = -radius; i < radius; ++i) { 179 | for (int j = -radius; j < radius; ++j) { 180 | for (int k = -radius; k < radius; ++k) { 181 | Block block = world.getBlockAt(j + x_center, i + y_center, k + z_center); 182 | 183 | Material material = block.getType(); 184 | BlockState blockState = block.getState(); 185 | 186 | int type = toWebBlockType(material, blockState); 187 | data.setShortLE(offset, (short) type); 188 | offset += 2; 189 | 190 | // Gather block data updates 191 | String blockDataCommand = getDataBlockUpdateCommand(block.getLocation(), material, blockState); 192 | 193 | if (type != 0) thereIsAWorld = true; 194 | if (blockDataCommand != null) blockDataUpdates.add(blockDataCommand); 195 | } 196 | } 197 | } 198 | data.writerIndex(data.capacity()); 199 | 200 | // Send compressed block types 201 | try { 202 | // Compress with DeflateOutputStream, note _not_ GZIPOutputStream since that adds 203 | // gzip headers (see https://stackoverflow.com/questions/1838699/how-can-i-decompress-a-gzip-stream-with-zlib) 204 | // which miniz does not support (https://github.com/richgel999/miniz/blob/ec028ffe66e2da67eed208de3db66fcf72b24dac/miniz.h#L33) 205 | ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); 206 | DeflaterOutputStream gzipOutputStream = new DeflaterOutputStream(byteArrayOutputStream); 207 | byte[] bytes = new byte[data.readableBytes()]; 208 | data.readBytes(bytes); 209 | gzipOutputStream.write(bytes); 210 | gzipOutputStream.close(); 211 | 212 | byte[] gzipBytes = byteArrayOutputStream.toByteArray(); 213 | ByteBuf gzipBytesBuffer = Unpooled.wrappedBuffer(gzipBytes); 214 | webSocketServerThread.sendBinary(channel, gzipBytesBuffer); 215 | } catch (IOException ex) { 216 | webSocketServerThread.log(Level.WARNING, "Failed to compress chunk data to send to web client: "+ex); 217 | throw new RuntimeException(ex); 218 | } finally { 219 | data.release(); 220 | } 221 | 222 | // then block data and refresh 223 | for (String blockDataCommand : blockDataUpdates) { 224 | webSocketServerThread.sendLine(channel, blockDataCommand); 225 | } 226 | 227 | webSocketServerThread.sendLine(channel,"K,0,0,1"); 228 | webSocketServerThread.sendLine(channel, "R,0,0"); 229 | 230 | if (!thereIsAWorld) { 231 | webSocketServerThread.sendLine(channel, "T,No blocks sent (server misconfiguration, check x/y/z_center)"); 232 | webSocketServerThread.log(Level.WARNING, "No valid blocks were found centered around ("+ 233 | x_center + "," + y_center + "," + z_center + ") radius " + radius + 234 | ", try changing these values or blocks_to_web in the configuration. All blocks were air or missing!"); 235 | } 236 | 237 | // Move player on top of the new blocks 238 | int x_start = radius; 239 | int y_start = world.getHighestBlockYAt(x_center, z_center) - radius - y_offset; 240 | int z_start = radius; 241 | int rotation_x = 0; 242 | int rotation_y = 0; 243 | webSocketServerThread.sendLine(channel, "U,1," + x_start + "," + y_start + "," + z_start + "," + rotation_x + "," + rotation_y ); 244 | } 245 | 246 | public boolean withinSandboxRange(Location location) { 247 | int x = location.getBlockX(); 248 | int y = location.getBlockY(); 249 | int z = location.getBlockZ(); 250 | if (x >= x_center + radius || x < x_center - radius) { 251 | return false; 252 | } 253 | if (y >= y_center + radius || y < y_center - radius) { 254 | return false; 255 | } 256 | if (z >= z_center + radius || z < z_center - radius) { 257 | return false; 258 | } 259 | return true; 260 | } 261 | 262 | public Location toBukkitLocation(int x, int y, int z) { 263 | x += -radius + x_center; 264 | y += -radius + y_center - y_offset; 265 | z += -radius + z_center; 266 | 267 | Location location = new Location(world, x, y, z); 268 | 269 | return location; 270 | } 271 | 272 | public Location toBukkitPlayerLocation(double x, double y, double z) { 273 | x += -radius + x_center; 274 | y += -radius + y_center - y_offset; 275 | z += -radius + z_center; 276 | 277 | Location location = new Location(world, x, y, z); 278 | 279 | return location; 280 | } 281 | 282 | public int toWebLocationBlockX(Location location) { return location.getBlockX() - (-radius + x_center); } 283 | 284 | public int toWebLocationBlockY(Location location) { return location.getBlockY() - (-radius + y_center - y_offset); } 285 | 286 | public int toWebLocationBlockZ(Location location) { return location.getBlockZ() - (-radius + z_center); } 287 | 288 | public double toWebLocationEntityX(Location location) { return location.getX() - (-radius + x_center); } 289 | 290 | public double toWebLocationEntityY(Location location) { return location.getY() - (-radius + y_center - y_offset); } 291 | 292 | public double toWebLocationEntityZ(Location location) { return location.getZ() - (-radius + z_center); } 293 | 294 | // Handle the web client changing a block, update the bukkit world 295 | public void clientBlockUpdate(ChannelHandlerContext ctx, int x, int y, int z, int type) { 296 | if (!allowBreakPlaceBlocks) { 297 | webSocketServerThread.sendLine(ctx.channel(), "T,Breaking/placing blocks not allowed"); 298 | // TODO: set back to original block to revert on client 299 | return; 300 | } 301 | 302 | Location location = toBukkitLocation(x, y, z); 303 | 304 | if (!withinSandboxRange(location)) { 305 | webSocketServerThread.log(Level.FINEST, "client tried to modify outside of sandbox! "+location); // not severe, since not prevented client-side 306 | webSocketServerThread.sendLine(ctx.channel(), "T,You cannot build at ("+x+","+y+","+z+")"); 307 | // TODO: Clear the block, fix this (set to air) 308 | /* 309 | webSocketServerThread.sendLine(ctx.channel(), "B,0,0,"+ox+","+oy+","+oz+",0"); 310 | webSocketServerThread.sendLine(ctx.channel(), "R,0,0"); 311 | */ 312 | return; 313 | } 314 | 315 | Block previousBlock = location.getBlock(); 316 | Material previousMaterial = previousBlock.getType(); 317 | if (unbreakableBlocks.contains(previousMaterial)) { 318 | webSocketServerThread.log(Level.FINEST, "client tried to change unbreakable block at " + 319 | location + " of type previousMaterial="+previousMaterial); 320 | 321 | webSocketServerThread.sendLine(ctx.channel(), "T,You cannot break blocks of type " + previousMaterial); 322 | 323 | // Revert on client 324 | int previousType = toWebBlockType(previousMaterial, null); 325 | webSocketServerThread.sendLine(ctx.channel(), "B,0,0,"+x+","+y+","+z+","+previousType); 326 | webSocketServerThread.sendLine(ctx.channel(), "R,0,0"); 327 | return; 328 | } 329 | 330 | Block block = world.getBlockAt(location); 331 | if (block == null) { 332 | webSocketServerThread.log(Level.WARNING, "web client no such block at " + location); // does this happen? 333 | return; 334 | } 335 | 336 | webSocketServerThread.log(Level.FINEST, "setting block at "+location); 337 | 338 | BlockState blockState = block.getState(); 339 | toBukkitBlockType(type, blockState); 340 | 341 | // Notify other web clients - note they will have the benefit of seeing the untranslated block (feature or bug?) 342 | webSocketServerThread.broadcastLineExcept(ctx.channel().id(), "B,0,0," + x + "," + y + "," + z + "," + type); 343 | webSocketServerThread.broadcastLineExcept(ctx.channel().id(), "R,0,0"); 344 | } 345 | 346 | 347 | // Handle the bukkit world changing a block, tell all web clients and refresh 348 | public void notifyBlockUpdate(Location location, Material material, BlockState blockState) { 349 | webSocketServerThread.log(Level.FINEST, "bukkit block at "+location+" was set to "+material); 350 | 351 | if (!withinSandboxRange(location)) { 352 | // Clients don't need to know about every block change on the server, only within the sandbox 353 | return; 354 | } 355 | 356 | setBlockUpdate(location, material, blockState); 357 | 358 | webSocketServerThread.broadcastLine("R,0,0"); 359 | } 360 | 361 | // Get the command string to send block data besides the type, if needed (signs, lighting) 362 | private String getDataBlockUpdateCommand(Location location, Material material, BlockState blockState) { 363 | if (material == null || material == Material.AIR) return null; 364 | 365 | int light_level = toWebLighting(material, blockState); 366 | if (light_level != 0) { 367 | int x = toWebLocationBlockX(location); 368 | int y = toWebLocationBlockY(location); 369 | int z = toWebLocationBlockZ(location); 370 | return "L,0,0,"+x+","+y+","+z+"," + light_level; 371 | } 372 | 373 | if (blockState instanceof org.bukkit.block.Sign) { 374 | Sign sign = (Sign) blockState; 375 | 376 | return getNotifySignChange(blockState.getLocation(), blockState.getType(), blockState, sign.getLines()); 377 | } 378 | 379 | return null; 380 | } 381 | 382 | private void setBlockUpdate(Location location, Material material, BlockState blockState) { 383 | // Send to all web clients to let them know it changed using the "B," command 384 | int type = toWebBlockType(material, blockState); 385 | 386 | if (type == -1) { 387 | if (warnMissing) { 388 | webSocketServerThread.log(Level.WARNING, "Block type missing from blocks_to_web: " + material + " at " + location); 389 | } 390 | type = blocksToWebMissing; 391 | } 392 | 393 | int x = toWebLocationBlockX(location); 394 | int y = toWebLocationBlockY(location); 395 | int z = toWebLocationBlockZ(location); 396 | 397 | webSocketServerThread.broadcastLine("B,0,0,"+x+","+y+","+z+","+type); 398 | String blockDataCommand = this.getDataBlockUpdateCommand(location, material, blockState); 399 | if (blockDataCommand != null) { 400 | webSocketServerThread.broadcastLine(blockDataCommand); 401 | } 402 | 403 | webSocketServerThread.log(Level.FINEST, "notified block update: ("+x+","+y+","+z+") to "+type); 404 | } 405 | 406 | private int toWebLighting(Material material, BlockState blockState) { 407 | BlockData blockData = blockState.getBlockData(); 408 | boolean isLit = false; 409 | 410 | if (blockData instanceof Lightable) { 411 | Lightable lightable = (Lightable) blockData; 412 | isLit = lightable.isLit(); 413 | } 414 | // See http://minecraft.gamepedia.com/Light#Blocks 415 | // Note not all of these may be fully supported yet 416 | switch (material) { 417 | case BEACON: 418 | case FIRE: 419 | case GLOWSTONE: 420 | case JACK_O_LANTERN: 421 | case LAVA: 422 | case REDSTONE_LAMP: // TODO: get notified when toggles on/off 423 | if (!isLit) { 424 | return 0; 425 | } 426 | 427 | case SEA_LANTERN: 428 | case END_ROD: 429 | return 15; 430 | 431 | case TORCH: 432 | return 14; 433 | 434 | case FURNACE: 435 | if (blockData instanceof org.bukkit.block.data.type.Furnace) { // TODO: or is Lightable too? 436 | org.bukkit.block.data.type.Furnace furnace = (org.bukkit.block.data.type.Furnace) blockData; 437 | if (!furnace.isLit()) { 438 | return 0; 439 | } 440 | } 441 | return 13; 442 | 443 | case END_PORTAL: 444 | case NETHER_PORTAL: 445 | return 11; 446 | 447 | case REDSTONE_ORE: 448 | case DEEPSLATE_REDSTONE_ORE: 449 | if (!isLit) { 450 | return 0; 451 | } 452 | return 9; 453 | 454 | case ENDER_CHEST: 455 | case REDSTONE_TORCH: 456 | if (!isLit) { 457 | return 0; 458 | } 459 | return 7; 460 | 461 | case MAGMA_BLOCK: 462 | return 3; 463 | 464 | case BREWING_STAND: 465 | case BROWN_MUSHROOM: 466 | case DRAGON_EGG: 467 | case END_PORTAL_FRAME: 468 | return 1; 469 | default: 470 | return 0; 471 | } 472 | } 473 | 474 | // The web client represents directional blocks has four block ids 475 | // example: furnaces 476 | private int getDirectionalOrthogonalWebBlock(int base, Directional directional) { 477 | BlockFace facing = directional.getFacing(); 478 | switch (facing) { 479 | case NORTH: return base+0; 480 | case SOUTH: return base+1; 481 | case WEST: return base+2; 482 | case EAST: return base+3; 483 | default: 484 | webSocketServerThread.log(Level.WARNING, "unknown orthogonal directional rotation: "+facing); 485 | return base; 486 | } 487 | } 488 | 489 | // example: pumpkins, for some reason, fronts are inverted 490 | private int getDirectionalOrthogonalWebBlockReversed(int base, Directional directional) { 491 | BlockFace facing = directional.getFacing(); 492 | switch (facing) { 493 | case SOUTH: return base+0; 494 | case NORTH: return base+1; 495 | case EAST: return base+2; 496 | case WEST: return base+3; 497 | default: 498 | webSocketServerThread.log(Level.WARNING, "unknown orthogonal directional rotation: "+facing); 499 | return base; 500 | } 501 | } 502 | 503 | // Translate web<->bukkit blocks 504 | // TODO: refactor to remove all bukkit dependency in this class (enums strings?), generalize to can support others 505 | private int toWebBlockType(Material material, BlockState blockState) { 506 | if (blocksToWeb.containsKey(material)) { 507 | return blocksToWeb.get(material); 508 | } 509 | 510 | BlockData blockData = blockState != null ? blockState.getBlockData() : null; 511 | 512 | Directional directional = null; 513 | if (blockData instanceof Directional) { 514 | directional = (Directional) blockData; 515 | } 516 | 517 | boolean isLit = false; 518 | if (blockData instanceof Lightable) { 519 | Lightable lightable = (Lightable) blockData; 520 | isLit = lightable.isLit(); 521 | } 522 | 523 | switch (material) { 524 | case AIR: return 0; 525 | case GRASS_BLOCK: return 1; 526 | case SAND: return 2; 527 | case SMOOTH_STONE: return 3; // TODO: 3 is smooth stone brick, what is this? 528 | case MOSSY_STONE_BRICKS: return 76; // mossy stone brick 529 | case CRACKED_STONE_BRICKS: return 77; // cracked stone brick 530 | case BRICKS: return 4; 531 | 532 | case OAK_LOG: // log = block found in trees 533 | case OAK_WOOD: // wood = bark block 534 | case STRIPPED_OAK_LOG: 535 | case STRIPPED_OAK_WOOD: 536 | 537 | case JUNGLE_LOG: 538 | case JUNGLE_WOOD: 539 | case STRIPPED_JUNGLE_LOG: 540 | case STRIPPED_JUNGLE_WOOD: 541 | 542 | case ACACIA_LOG: 543 | case ACACIA_WOOD: 544 | case STRIPPED_ACACIA_LOG: 545 | case STRIPPED_ACACIA_WOOD: 546 | 547 | case DARK_OAK_LOG: 548 | case DARK_OAK_WOOD: 549 | case STRIPPED_DARK_OAK_LOG: 550 | case STRIPPED_DARK_OAK_WOOD: 551 | 552 | return 5; // oak wood log 553 | 554 | case SPRUCE_LOG: 555 | case STRIPPED_SPRUCE_LOG: 556 | return 107; // spruce wood log 557 | 558 | case BIRCH_LOG: 559 | case STRIPPED_BIRCH_LOG: 560 | return 108; // birch wood log 561 | 562 | // TODO: tree.getDirection(), faces different 563 | 564 | case GOLD_ORE: return 70; 565 | case IRON_ORE: return 71; 566 | case COAL_ORE: return 72; 567 | case LAPIS_ORE: return 73; 568 | case LAPIS_BLOCK: return 74; 569 | case DIAMOND_ORE: return 48; 570 | case REDSTONE_ORE: return 49; 571 | // TODO: more ores, for now, showing as stone 572 | case NETHER_QUARTZ_ORE: return 6; 573 | case STONE: return 6; 574 | case DIRT: return 7; 575 | 576 | case OAK_PLANKS: 577 | case SPRUCE_PLANKS: 578 | case BIRCH_PLANKS: 579 | case JUNGLE_PLANKS: 580 | case ACACIA_PLANKS: 581 | case DARK_OAK_PLANKS: 582 | case CRIMSON_PLANKS: 583 | return 8; // plank 584 | 585 | case SNOW: return 9; 586 | 587 | case GLASS: return 10; 588 | case COBBLESTONE: return 11; 589 | 590 | case CHEST: return 14; 591 | 592 | case OAK_LEAVES: 593 | case DARK_OAK_LEAVES: 594 | case ACACIA_LEAVES: 595 | case BIRCH_LEAVES: 596 | return 15; // leaves 597 | 598 | case SPRUCE_LEAVES: // "redwood 599 | return 109; // spruce leaves 600 | 601 | // TODO: return cloud (16); 602 | 603 | case GRASS: 604 | case TALL_GRASS: return 17; 605 | 606 | // TODO: other double plants, but a lot look like longer long grass 607 | 608 | case FERN: return 29; // fern 609 | 610 | case DEAD_BUSH: return 23; // deadbush, places on sand 611 | case DANDELION: return 18; 612 | case POPPY: 613 | case ALLIUM: 614 | case AZURE_BLUET: 615 | case RED_TULIP: 616 | case ORANGE_TULIP: 617 | case PINK_TULIP: 618 | case OXEYE_DAISY: 619 | case CORNFLOWER: 620 | case LILY_OF_THE_VALLEY: 621 | case WITHER_ROSE: 622 | case SPORE_BLOSSOM: 623 | return 19; // red rose 624 | 625 | case CHORUS_FLOWER: return 20; 626 | 627 | case OAK_SAPLING: 628 | case DARK_OAK_SAPLING: 629 | case ACACIA_SAPLING: 630 | case JUNGLE_SAPLING: 631 | return 20; // oak sapling 632 | 633 | case SPRUCE_SAPLING: 634 | return 30; // spruce sapling ("darker barked/leaves tree species") 635 | 636 | case BIRCH_SAPLING: 637 | return 31; // birch sapling 638 | 639 | case SUNFLOWER: return 21; 640 | case WHITE_TULIP: return 22; // white flower 641 | case BLUE_ORCHID: return 23; // blue flower 642 | 643 | case WHITE_WOOL: return 32; 644 | case ORANGE_WOOL: return 33; 645 | case MAGENTA_WOOL: return 34; 646 | case LIGHT_BLUE_WOOL: return 35; 647 | case YELLOW_WOOL: return 36; 648 | case LIME_WOOL: return 37; 649 | case PINK_WOOL: return 38; 650 | case GRAY_WOOL: return 39; 651 | case LIGHT_GRAY_WOOL: return 40; // formerly silver 652 | case CYAN_WOOL: return 41; 653 | case PURPLE_WOOL: return 42; 654 | case BLUE_WOOL: return 43; 655 | case BROWN_WOOL: return 44; 656 | case GREEN_WOOL: return 45; 657 | case BLACK_WOOL: return 47; 658 | 659 | case OAK_WALL_SIGN: 660 | case SPRUCE_WALL_SIGN: 661 | case BIRCH_WALL_SIGN: 662 | case JUNGLE_WALL_SIGN: 663 | case DARK_OAK_WALL_SIGN: 664 | case CRIMSON_WALL_SIGN: 665 | return 0; // air, since text is written on block behind it 666 | 667 | case OAK_SIGN: 668 | case SPRUCE_SIGN: 669 | case BIRCH_SIGN: 670 | case JUNGLE_SIGN: 671 | case DARK_OAK_SIGN: 672 | case CRIMSON_SIGN: 673 | return 8; // plank TODO: return sign post model 674 | 675 | // Light sources (nonzero toWebLighting()) TODO: return different textures? + allow placement, distinct blocks 676 | case GLOWSTONE: return 64; // #define GLOWING_STONE 677 | case SEA_LANTERN: return 35; // light blue wool 678 | case TORCH: return 21; // sunflower, looks kinda like a torch 679 | case REDSTONE_TORCH: 680 | case REDSTONE_WALL_TORCH: 681 | return 19; // red flower, vaguely a torch 682 | 683 | // Liquids 684 | // TODO: flowing, stationary, and different heights of each liquid 685 | case WATER: 686 | return 12; // water 687 | case LAVA: 688 | return 13; // lava 689 | 690 | // TODO: support more blocks by default 691 | case BEDROCK: return 65; 692 | case GRAVEL: return 66; 693 | case IRON_BLOCK: return 67; 694 | case GOLD_BLOCK: return 68; 695 | case DIAMOND_BLOCK: return 69; 696 | case SANDSTONE: return 75; 697 | case BOOKSHELF: return 50; 698 | case MOSSY_COBBLESTONE: return 51; 699 | case OBSIDIAN: return 52; 700 | case CRAFTING_TABLE: return 53; 701 | case FURNACE: { 702 | if (directional != null) { 703 | if (!isLit) { 704 | return getDirectionalOrthogonalWebBlock(90, directional); // 90, 91, 92, 93 705 | } else { 706 | return getDirectionalOrthogonalWebBlock(94, directional); // 94, 95, 96, 97 707 | } 708 | } 709 | return !isLit ? 90 : 94; 710 | //return !isLit ? 54 : 55; // old 711 | } 712 | 713 | case SPAWNER: return 56; 714 | case SNOW_BLOCK: return 57; 715 | case ICE: return 58; 716 | case CLAY: return 59; 717 | case JUKEBOX: return 60; 718 | case CACTUS: return 61; 719 | case MYCELIUM: return 62; 720 | case NETHERRACK: return 63; 721 | case SPONGE: return 24; 722 | case MELON: return 25; 723 | case END_STONE: return 26; 724 | case TNT: return 27; 725 | case EMERALD_BLOCK: return 28; 726 | case CARVED_PUMPKIN: { 727 | if (directional != null) { 728 | return getDirectionalOrthogonalWebBlockReversed(98, directional); // 98, 99, 100, 101 729 | } 730 | } 731 | case PUMPKIN: 732 | return 78; // faceless 733 | 734 | case JACK_O_LANTERN: { 735 | if (directional != null) { 736 | return getDirectionalOrthogonalWebBlockReversed(102, directional); // 102, 103, 104, 105 737 | } 738 | 739 | return 79; // all faces 740 | } 741 | case BROWN_MUSHROOM_BLOCK: return 80; // brown TODO: data 742 | case RED_MUSHROOM_BLOCK: return 81; // red TODO: data 743 | case COMMAND_BLOCK: return 82; 744 | case EMERALD_ORE: return 83; 745 | case SOUL_SAND: return 84; 746 | case NETHER_BRICK: return 85; 747 | case FARMLAND: return 86; // wet farmland TODO: dry farmland (87) 748 | case REDSTONE_LAMP: 749 | return !isLit ? 88 : 89; 750 | 751 | case BARRIER: return 106; 752 | default: return this.blocksToWebMissing; 753 | } 754 | } 755 | 756 | // Mutate blockState to block of type type 757 | private void toBukkitBlockType(int type, BlockState blockState) { 758 | Material material = null; 759 | BlockData blockData = null; 760 | 761 | switch (type) { 762 | case 0: material = Material.AIR; break; 763 | case 1: material = Material.GRASS; break; 764 | case 2: material = Material.SAND; break; 765 | case 3: material = Material.SMOOTH_STONE; break; // TODO: what is smooth brick? 766 | case 4: material = Material.BRICK; break; 767 | case 5: material = Material.OAK_LOG; break; 768 | case 6: material = Material.STONE; break; 769 | case 7: material = Material.DIRT; break; 770 | case 8: material = Material.OAK_WOOD; break; // TODO: this was WOOD, is this right? bark? 771 | case 9: material = Material.SNOW_BLOCK; break; 772 | case 10: material = Material.GLASS; break; 773 | case 11: material = Material.COBBLESTONE; break; 774 | case 12: material = Material.WATER; break; 775 | case 13: material = Material.LAVA; break; 776 | case 14: material = Material.CHEST; break; // TODO: doesn't seem to set? 777 | case 15: material = Material.OAK_LEAVES; break; 778 | //case 16: material = Material.clouds; break; // clouds 779 | case 17: material = Material.TALL_GRASS; break; 780 | case 18: material = Material.DANDELION; break; 781 | case 19: material = Material.RED_TULIP; break; 782 | case 20: material = Material.CHORUS_FLOWER; break; 783 | case 21: material = Material.RED_MUSHROOM; break; 784 | case 22: material = Material.BROWN_MUSHROOM; break; 785 | case 23: material = Material.DEAD_BUSH; break; 786 | case 24: material = Material.SPONGE; break; 787 | case 25: material = Material.MELON; break; 788 | case 26: material = Material.END_STONE; break; 789 | case 27: material = Material.TNT; break; 790 | case 28: material = Material.EMERALD_BLOCK; break; 791 | 792 | case 29: material = Material.FERN; break; 793 | case 30: material = Material.SPRUCE_SAPLING; break; 794 | case 31: material = Material.BIRCH_SAPLING; break; 795 | 796 | case 32: material = Material.WHITE_WOOL; break; 797 | case 33: material = Material.ORANGE_WOOL; break; 798 | case 34: material = Material.MAGENTA_WOOL; break; 799 | case 35: material = Material.LIGHT_BLUE_WOOL; break; 800 | case 36: material = Material.YELLOW_WOOL; break; 801 | case 37: material = Material.LIME_WOOL; break; 802 | case 38: material = Material.PINK_WOOL; break; 803 | case 39: material = Material.GRAY_WOOL; break; 804 | case 40: material = Material.LIGHT_GRAY_WOOL; break; 805 | case 41: material = Material.CYAN_WOOL; break; 806 | case 42: material = Material.PURPLE_WOOL; break; 807 | case 43: material = Material.BLUE_WOOL; break; 808 | case 44: material = Material.BROWN_WOOL; break; 809 | case 45: material = Material.GREEN_WOOL; break; 810 | case 46: material = Material.RED_WOOL; break; 811 | case 47: material = Material.BLACK_WOOL; break; 812 | 813 | case 48: material = Material.DIAMOND_ORE; break; 814 | case 49: material = Material.REDSTONE_ORE; break; 815 | case 50: material = Material.BOOKSHELF; break; 816 | case 51: material = Material.MOSSY_COBBLESTONE; break; 817 | case 52: material = Material.OBSIDIAN; break; 818 | case 53: material = Material.CRAFTING_TABLE; break; 819 | 820 | case 54: // TODO: remove, what was this? 821 | case 55: // TODO: remove, what was this? 822 | material = Material.FURNACE; 823 | break; 824 | 825 | case 56: material = Material.AIR; break; // not allowing, dangerous Material.MOB_SPAWNER 826 | case 57: material = Material.SNOW_BLOCK; break; 827 | case 58: material = Material.ICE; break; 828 | case 59: material = Material.CLAY; break; 829 | case 60: material = Material.JUKEBOX; break; 830 | case 61: material = Material.CACTUS; break; 831 | case 62: material = Material.MYCELIUM; break; 832 | case 63: material = Material.NETHERRACK; break; 833 | case 64: material = Material.GLOWSTONE; break; 834 | case 65: material = Material.BEDROCK; break; 835 | case 66: material = Material.GRAVEL; break; 836 | case 67: material = Material.IRON_BLOCK; break; 837 | case 68: material = Material.GOLD_BLOCK; break; 838 | case 69: material = Material.DIAMOND_BLOCK; break; 839 | case 70: material = Material.GOLD_ORE; break; 840 | case 71: material = Material.IRON_ORE; break; 841 | case 72: material = Material.COAL_ORE; break; 842 | case 73: material = Material.LAPIS_ORE; break; 843 | case 74: material = Material.LAPIS_BLOCK; break; 844 | case 75: material = Material.SANDSTONE; break; 845 | case 76: material = Material.MOSSY_COBBLESTONE; break; // TODO: mossy stone brick 846 | case 77: material = Material.MOSSY_COBBLESTONE; break; // TODO: cracked stone brick 847 | case 78: material = Material.PUMPKIN; break; // TODO: direction 848 | case 79: material = Material.JACK_O_LANTERN; break; // TODO: direction 849 | case 80: material = Material.BROWN_MUSHROOM_BLOCK; break; // TODO: type 850 | case 81: material = Material.RED_MUSHROOM_BLOCK; break; // TODO: type 851 | case 82: material = Material.AIR; break; // not ever allowing Material.COMMAND; break; 852 | case 83: material = Material.EMERALD_ORE; break; 853 | case 84: material = Material.SOUL_SAND; break; 854 | case 85: material = Material.NETHER_BRICK; break; 855 | case 86: material = Material.FARMLAND; break; 856 | //case 87: // TODO: dry farmland 857 | case 88: 858 | case 89: 859 | material = Material.REDSTONE_LAMP; 860 | // TODO: set lit 861 | //Lightable lit = new Lightable(); 862 | //lit.setLit(type == 89); // lamp on 863 | break; 864 | 865 | case 90: 866 | case 91: 867 | case 92: 868 | case 93: 869 | 870 | case 94: 871 | case 95: 872 | case 96: 873 | case 97: 874 | material = Material.FURNACE; // TODO: direction 875 | // TODO: set lit 876 | //org.bukkit.block.data.type.Furnace f = new org.bukkit.block.data.type.Furnace(); // TODO: how to construct this? it is an interface, do we have to get after? 877 | //f.setLit(type >= 94); // burning 878 | // TODO: set direction 879 | break; 880 | 881 | case 98: 882 | case 99: 883 | case 100: 884 | case 101: 885 | material = Material.PUMPKIN; break; // TODO: direction 886 | 887 | case 102: 888 | case 103: 889 | case 104: 890 | case 105: 891 | material = Material.JACK_O_LANTERN; break; // TODO: direction 892 | 893 | case 106: material = Material.AIR; break; // not allowing Material.BARRIER; break; 894 | case 107: material = Material.SPRUCE_WOOD; break; // spruce wood log 895 | case 108: material = Material.BIRCH_WOOD; break; // birch wood log 896 | case 109: material = Material.SPRUCE_LEAVES; break; // TODO: spruce leaves 897 | 898 | default: 899 | webSocketServerThread.log(Level.WARNING, "untranslated web block id "+type); 900 | material = Material.DIAMOND_ORE; // placeholder TODO fix 901 | } 902 | 903 | if (unbreakableBlocks.contains(material)) { 904 | webSocketServerThread.log(Level.WARNING, "client tried to place unplaceable block type "+type+ " from "+material); 905 | return; // ignore, not reverting 906 | } 907 | 908 | if (material != null) { 909 | blockState.setType(material); 910 | 911 | if (blockData != null) { 912 | blockState.setBlockData(blockData); 913 | } 914 | 915 | boolean force = true; 916 | boolean applyPhysics = false; 917 | blockState.update(force, applyPhysics); 918 | } 919 | } 920 | 921 | public String getNotifySignChange(Location location, Material material, BlockState blockState, String[] lines) { 922 | int x = toWebLocationBlockX(location); 923 | int y = toWebLocationBlockY(location); 924 | int z = toWebLocationBlockZ(location); 925 | BlockFace blockFace = BlockFace.NORTH; 926 | BlockData blockData = blockState.getBlockData(); 927 | 928 | if (blockData instanceof Directional) { 929 | //org.bukkit.block.data.type.Sign sign = (org.bukkit.block.data.type.Sign) blockData; 930 | Directional directional = (Directional) blockData; 931 | 932 | try { 933 | blockFace = directional.getFacing(); 934 | } catch (NullPointerException ex) { 935 | // ignore invalid data, https://github.com/GlowstoneMC/Glowstone/issues/484 936 | webSocketServerThread.log(Level.WARNING, "Invalid sign data at " + location); 937 | } 938 | } 939 | 940 | int face = 7; 941 | if (blockFace == null) { 942 | // https://github.com/satoshinm/WebSandboxMC/issues/92 943 | webSocketServerThread.log(Level.WARNING, "Invalid sign face at " + location); 944 | 945 | } else if (material == Material.OAK_WALL_SIGN || 946 | material == Material.SPRUCE_WALL_SIGN || 947 | material == Material.BIRCH_WALL_SIGN || 948 | material == Material.JUNGLE_WALL_SIGN || 949 | material == Material.DARK_OAK_WALL_SIGN || 950 | material == Material.CRIMSON_WALL_SIGN) { 951 | // wallsigns, attached to block behind 952 | switch (blockFace) { 953 | default: 954 | case NORTH: 955 | face = 2; // north 956 | z += 1; 957 | break; 958 | case SOUTH: 959 | face = 3; // south 960 | z -= 1; 961 | break; 962 | case WEST: 963 | face = 0; // west 964 | x += 1; 965 | break; 966 | case EAST: 967 | face = 1; // east 968 | x -= 1; 969 | break; 970 | } 971 | } else if (material == Material.OAK_SIGN || 972 | material == Material.SPRUCE_SIGN || 973 | material == Material.BIRCH_SIGN || 974 | material == Material.JUNGLE_SIGN || 975 | material == Material.DARK_OAK_SIGN || 976 | material == Material.CRIMSON_SIGN) { 977 | // standing sign, on the block itself 978 | // TODO: support more fine-grained directions, right now Craft only four cardinal 979 | switch (blockFace) { 980 | case SOUTH: 981 | case SOUTH_SOUTH_WEST: 982 | case SOUTH_WEST: 983 | face = 3; // south 984 | break; 985 | 986 | case WEST_SOUTH_WEST: 987 | case WEST: 988 | case WEST_NORTH_WEST: 989 | case NORTH_WEST: 990 | face = 0; // west 991 | break; 992 | 993 | default: 994 | case NORTH_NORTH_WEST: 995 | case NORTH: 996 | case NORTH_NORTH_EAST: 997 | case NORTH_EAST: 998 | face = 2; // north 999 | break; 1000 | 1001 | case EAST_NORTH_EAST: 1002 | case EAST: 1003 | case EAST_SOUTH_EAST: 1004 | case SOUTH_EAST: 1005 | case SOUTH_SOUTH_EAST: 1006 | face = 1; // east 1007 | break; 1008 | } 1009 | } 1010 | 1011 | webSocketServerThread.log(Level.FINEST, "sign change: "+location+", blockFace="+blockFace); 1012 | String text = ""; 1013 | for (int i = 0; i < lines.length; ++i) { 1014 | text += lines[i] + " "; // TODO: support explicit newlines; Craft wraps sign text lines automatically 1015 | } 1016 | if (text.contains("\n")) { 1017 | // \n is used as a command terminator in the Craft protocol (but ',' is acceptable) 1018 | text = text.replaceAll("\n", " "); 1019 | } 1020 | 1021 | return "S,0,0,"+x+","+y+","+z+","+face+","+text; 1022 | } 1023 | 1024 | public void notifySignChange(Location location, Material material, BlockState blockState, String[] lines) { 1025 | webSocketServerThread.broadcastLine(this.getNotifySignChange(location, material, blockState, lines)); 1026 | webSocketServerThread.broadcastLine("R,0,0"); // TODO: refresh correct chunk 1027 | } 1028 | 1029 | public void clientNewSign(ChannelHandlerContext ctx, int x, int y, int z, int face, String text) { 1030 | if (!allowSigns) { 1031 | webSocketServerThread.sendLine(ctx.channel(), "T,Writing on signs is not allowed"); 1032 | // TODO: revert on client 1033 | return; 1034 | } 1035 | 1036 | BlockFace blockFace; 1037 | switch (face) { 1038 | case 0: // west 1039 | blockFace = BlockFace.WEST; 1040 | x -= 1; 1041 | break; 1042 | case 1: // east 1043 | blockFace = BlockFace.EAST; 1044 | x += 1; 1045 | break; 1046 | default: 1047 | case 2: // north 1048 | blockFace = BlockFace.NORTH; 1049 | z -= 1; 1050 | break; 1051 | case 3: // south 1052 | blockFace = BlockFace.SOUTH; 1053 | z += 1; 1054 | break; 1055 | } 1056 | 1057 | 1058 | Location location = toBukkitLocation(x, y, z); 1059 | if (!withinSandboxRange(location)) { 1060 | webSocketServerThread.log(Level.FINEST, "client tried to write a sign outside sandbox range"); 1061 | return; 1062 | } 1063 | 1064 | // Create the sign 1065 | Block block = location.getWorld().getBlockAt(location); 1066 | webSocketServerThread.log(Level.FINEST, "setting sign at "+location+" text="+text); 1067 | block.setType(Material.OAK_WALL_SIGN); 1068 | 1069 | BlockState blockState = block.getState(); 1070 | if (!(blockState instanceof Sign)) { 1071 | webSocketServerThread.log(Level.WARNING, "failed to place sign at "+location); 1072 | return; 1073 | } 1074 | 1075 | Sign sign = (Sign) blockState; 1076 | 1077 | // Set the sign text 1078 | // TODO: text lines by 15 characters into 5 lines 1079 | sign.setLine(0, text); 1080 | webSocketServerThread.log(Level.FINEST, "set sign text="+text+", sign="+sign+", blockFace="+blockFace+", block="+block+", face="+face); 1081 | 1082 | // Set sign direction 1083 | BlockData blockData = blockState.getBlockData(); 1084 | if (!(blockData instanceof Directional)) { 1085 | webSocketServerThread.log(Level.WARNING, "failed to get sign directional block data at " + location); 1086 | } 1087 | 1088 | Directional directional = (Directional) blockData; 1089 | directional.setFacing(blockFace); 1090 | webSocketServerThread.log(Level.FINEST, "setting sign at "+location+" blockFace="+blockFace); 1091 | 1092 | blockState.setBlockData(directional); 1093 | 1094 | boolean force = true; 1095 | boolean applyPhysics = false; 1096 | sign.update(force, applyPhysics); 1097 | blockState.update(force, applyPhysics); 1098 | webSocketServerThread.log(Level.FINEST, "updated sign at "+location); 1099 | 1100 | // SignChangeEvent not posted when signs created programmatically; notify web clients ourselves 1101 | notifySignChange(location, block.getType(), block.getState(), sign.getLines()); 1102 | } 1103 | } 1104 | --------------------------------------------------------------------------------