diff --git a/game/src/main/org/apollo/Server.java b/game/src/main/org/apollo/Server.java index f1df758b..a60a7ae0 100644 --- a/game/src/main/org/apollo/Server.java +++ b/game/src/main/org/apollo/Server.java @@ -76,16 +76,16 @@ public final class Server { */ private final ServerBootstrap jaggrabBootstrap = new ServerBootstrap(); - /** - * The {@link ServerBootstrap} for the service listener. - */ - private final ServerBootstrap serviceBootstrap = new ServerBootstrap(); - /** * The event loop group. */ private final EventLoopGroup loopGroup = new NioEventLoopGroup(); + /** + * The {@link ServerBootstrap} for the service listener. + */ + private final ServerBootstrap serviceBootstrap = new ServerBootstrap(); + /** * Creates the Apollo server. */ @@ -99,18 +99,17 @@ public final class Server { * @param service The service address to bind to. * @param http The HTTP address to bind to. * @param jaggrab The JAGGRAB address to bind to. - * @throws BindException If the ServerBootstrap fails to bind to the SocketAddress for any - * reason. + * @throws BindException If the ServerBootstrap fails to bind to the SocketAddress. */ public void bind(SocketAddress service, SocketAddress http, SocketAddress jaggrab) throws BindException { logger.fine("Binding service listener to address: " + service + "..."); bind(serviceBootstrap, service); try { - logger.fine("Binding HTTP listener to address: " + http + "..."); - bind(httpBootstrap, http); + logger.fine("Binding HTTP listener to address: " + http + "..."); + bind(httpBootstrap, http); } catch (Exception cause) { - logger.warning("Unable to bind to HTTP, JAGGRAB will be used as a fallback however this is not recommended."); + logger.warning("Unable to bind to HTTP, JAGGRAB will be used as a fallback however this is not recommended."); } logger.fine("Binding JAGGRAB listener to address: " + jaggrab + "..."); @@ -119,22 +118,6 @@ public final class Server { logger.info("Ready for connections."); } - /** - * Attempts to bind the specified ServerBootstrap to the specified SocketAddress. - * - * @param bootstrap The ServerBootstrap. - * @param address The SocketAddress. - * @throws BindException If the ServerBootstrap fails to bind to the SocketAddress for any - * reason. - */ - private void bind(ServerBootstrap bootstrap, SocketAddress address) throws BindException { - try { - bootstrap.bind(address).sync(); - } catch (Exception cause) { - throw new BindException("Failed to bind to: " + address); - } - } - /** * Initialises the server. * @@ -176,4 +159,19 @@ public final class Server { world.init(version, fs, manager); } + /** + * Attempts to bind the specified ServerBootstrap to the specified SocketAddress. + * + * @param bootstrap The ServerBootstrap. + * @param address The SocketAddress. + * @throws BindException If the ServerBootstrap fails to bind to the SocketAddress. + */ + private void bind(ServerBootstrap bootstrap, SocketAddress address) throws BindException { + try { + bootstrap.bind(address).sync(); + } catch (Exception cause) { + throw new BindException("Failed to bind to: " + address); + } + } + } \ No newline at end of file diff --git a/game/src/main/org/apollo/game/message/impl/GroupedRegionUpdateMessage.java b/game/src/main/org/apollo/game/message/impl/GroupedRegionUpdateMessage.java index cdb9747a..b563243e 100644 --- a/game/src/main/org/apollo/game/message/impl/GroupedRegionUpdateMessage.java +++ b/game/src/main/org/apollo/game/message/impl/GroupedRegionUpdateMessage.java @@ -1,6 +1,7 @@ package org.apollo.game.message.impl; import java.util.List; +import java.util.Set; import org.apollo.game.model.Position; import org.apollo.game.model.area.RegionCoordinates; @@ -18,24 +19,25 @@ public final class GroupedRegionUpdateMessage extends Message { */ private final Position lastKnownRegion; + /** + * The Set of RegionUpdateMessages to be sent. + */ + private final Set messages; + /** * The Position of the Region being updated. */ private final Position region; - /** - * The List of RegionUpdateMessages to be sent. - */ - private final List messages; - /** * Creates the GroupedRegionUpdateMessage. * * @param lastKnownRegion The last known region {@link Position} of the Player. * @param coordinates The {@link RegionCoordinates} of the Region being updated. - * @param messages The {@link List} of {@link RegionUpdateMessage}s. + * @param messages The {@link Set} of {@link RegionUpdateMessage}s. */ - public GroupedRegionUpdateMessage(Position lastKnownRegion, RegionCoordinates coordinates, List messages) { + public GroupedRegionUpdateMessage(Position lastKnownRegion, RegionCoordinates coordinates, + Set messages) { this.lastKnownRegion = lastKnownRegion; region = new Position(coordinates.getAbsoluteX(), coordinates.getAbsoluteY()); this.messages = messages; @@ -51,11 +53,11 @@ public final class GroupedRegionUpdateMessage extends Message { } /** - * Gets the {@link List} of {@link RegionUpdateMessage}s. + * Gets the {@link Set} of {@link RegionUpdateMessage}s. * - * @return The Collection. + * @return The Set. */ - public List getMessages() { + public Set getMessages() { return messages; } diff --git a/game/src/main/org/apollo/game/model/area/Region.java b/game/src/main/org/apollo/game/model/area/Region.java index 7c1f1577..d9095c04 100644 --- a/game/src/main/org/apollo/game/model/area/Region.java +++ b/game/src/main/org/apollo/game/model/area/Region.java @@ -2,7 +2,6 @@ package org.apollo.game.model.area; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -21,7 +20,6 @@ import org.apollo.game.model.entity.EntityType; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; /** @@ -79,14 +77,17 @@ public final class Region { private final CollisionMatrix[] matrices = CollisionMatrix.createMatrices(Position.HEIGHT_LEVELS, SIZE, SIZE); /** - * The Set containing RegionUpdateMessages which can be sent to add every non-Mob Entity in this Region. + * The List of Sets containing RegionUpdateMessages that specifically remove StaticGameObjects. The + * List is ordered based on the height level the RegionUpdateMessages concern. */ - private final List> snapshots = new ArrayList<>(Position.HEIGHT_LEVELS); + private final List> removedObjects = new ArrayList<>(Position.HEIGHT_LEVELS); /** - * The Set containing UpdateOperations. + * The List of Sets containing RegionUpdateMessages. The List is ordered based on the height level the + * RegionUpdateMessages concern. This only contains the updates to this Region that have occurred in the last + * pulse. */ - private final List> updates = new ArrayList<>(Position.HEIGHT_LEVELS); + private final List> updates = new ArrayList<>(Position.HEIGHT_LEVELS); /** * Creates a new Region. @@ -108,8 +109,8 @@ public final class Region { listeners.add(new UpdateRegionListener()); for (int height = 0; height < Position.HEIGHT_LEVELS; height++) { - snapshots.add(new HashMap<>()); - updates.add(new ArrayList<>(DEFAULT_LIST_SIZE)); + removedObjects.add(new HashSet<>()); + updates.add(new HashSet<>(DEFAULT_LIST_SIZE)); } } @@ -154,7 +155,6 @@ public final class Region { * @param entity The Entity. * @return {@code true} if this Region contains the Entity, otherwise {@code false}. */ - public boolean contains(Entity entity) { Position position = entity.getPosition(); Set local = entities.get(position); @@ -162,6 +162,22 @@ public final class Region { return local != null && local.contains(entity); } + /** + * Encodes the contents of this Region into a {@link Set} of {@link RegionUpdateMessage}s, to be sent to a client. + * + * @return The Set of RegionUpdateMessages. + */ + public Set encode(int height) { + Set additions = entities.values().stream() + .flatMap(Set::stream).filter(entity -> entity instanceof GroupableEntity) + .map(entity -> ((GroupableEntity) entity).toUpdateOperation(this, EntityUpdateType.ADD).toMessage()) + .collect(Collectors.toSet()); + + ImmutableSet.Builder builder = ImmutableSet.builder(); + builder.addAll(additions).addAll(updates.get(height)).addAll(removedObjects.get(height)); + return builder.build(); + } + /** * Gets this Region's {@link RegionCoordinates}. * @@ -218,31 +234,14 @@ public final class Region { } /** - * Gets a {@link Set} containing {@link RegionUpdateMessage}s that add every {@link Entity} in this Region. + * Gets the {@link Set} of {@link RegionUpdateMessage}s that have occurred in the last pulse. This method can + * only be called once per pulse. * - * @param height The height level to get the Set of RegionUpdateMessages for. + * @param height The height level to get the RegionUpdateMessages for. * @return The Set of RegionUpdateMessages. */ - public List getSnapshot(int height) { - List copy = new ArrayList<>(snapshots.get(height).values()); - Collections.sort(copy); - return ImmutableList.copyOf(copy); - } - - /** - * Gets the updates that have occurred in the last tick in this Region, as a {@link Set} of - * {@link RegionUpdateMessage}s. - * - * @param height The height level to get the Set of RegionUpdateMessages for. - * @return The Set of RegionUpdateMessages. - */ - public List getUpdates(int height) { - List original = this.updates.get(height); - List updates = new ArrayList<>(original); - original.clear(); - - Collections.sort(updates); - return ImmutableList.copyOf(updates); + public Set getUpdates(int height) { + return ImmutableSet.copyOf(updates.get(height)); } /** @@ -261,15 +260,14 @@ public final class Region { * @param entity The Entity. * @throws IllegalArgumentException If the Entity does not belong in this Region, or if it was never added. */ - public void removeEntity(Entity entity) { + public void removeEntity(Entity entity) { // TODO entity update stuff Position position = entity.getPosition(); checkPosition(position); Set local = entities.get(position); if (local == null || !local.remove(entity)) { - throw new IllegalArgumentException("Entity (" + entity + ") belongs in this Region (" + this - + ") but does not exist."); + throw new IllegalArgumentException("Entity (" + entity + ") belongs in (" + this + ") but does not exist."); } notifyListeners(entity, EntityUpdateType.REMOVE); @@ -315,16 +313,19 @@ public final class Region { * @throws UnsupportedOperationException If the specified Entity cannot be operated on in this manner. */ private void record(T entity, EntityUpdateType type) { - RegionUpdateMessage message = entity.toUpdateOperation(this, type).toMessage(); + UpdateOperation operation = entity.toUpdateOperation(this, type); + RegionUpdateMessage message = operation.toMessage(), inverse = operation.inverse(); + int height = entity.getPosition().getHeight(); + Set updates = this.updates.get(height); - Map snapshot = snapshots.get(height); - updates.get(height).add(message); - - snapshot.remove(entity); - if ((entity.getEntityType() == EntityType.STATIC_OBJECT && type == EntityUpdateType.REMOVE) - || (entity.getEntityType() != EntityType.STATIC_OBJECT && type == EntityUpdateType.ADD)) { - snapshot.put(entity, message); + if (entity.getEntityType() == EntityType.STATIC_OBJECT && type == EntityUpdateType.REMOVE) { + removedObjects.get(height).add(message); + updates.add(message); + updates.remove(inverse); + } else { + updates.add(message); + updates.remove(inverse); } } diff --git a/game/src/main/org/apollo/game/model/area/update/UpdateOperation.java b/game/src/main/org/apollo/game/model/area/update/UpdateOperation.java index fe6736d8..710eded2 100644 --- a/game/src/main/org/apollo/game/model/area/update/UpdateOperation.java +++ b/game/src/main/org/apollo/game/model/area/update/UpdateOperation.java @@ -14,8 +14,8 @@ import com.google.common.base.Preconditions; * An type that is contained in the snapshot of a {@link Region}, which consists of an {@link Entity} being added, * removed, or moved. * - * @author Major * @param The type of {@link Entity} in this type. + * @author Major */ public abstract class UpdateOperation { @@ -47,6 +47,24 @@ public abstract class UpdateOperation { this.entity = entity; } + /** + * Gets a {@link RegionUpdateMessage} that would counteract the effect of this UpdateOperation. + * + * @return The RegionUpdateMessage. + */ + public final RegionUpdateMessage inverse() { + int offset = getPositionOffset(entity.getPosition()); + + switch (type) { + case ADD: + return remove(offset); + case REMOVE: + return add(offset); + default: + throw new IllegalStateException("Unsupported EntityUpdateType " + type + "."); + } + } + /** * Returns this UpdateOperation as a {@link Message}. * diff --git a/game/src/main/org/apollo/game/release/r317/GroupedRegionUpdateMessageEncoder.java b/game/src/main/org/apollo/game/release/r317/GroupedRegionUpdateMessageEncoder.java index aaab4c8c..fd4bff83 100644 --- a/game/src/main/org/apollo/game/release/r317/GroupedRegionUpdateMessageEncoder.java +++ b/game/src/main/org/apollo/game/release/r317/GroupedRegionUpdateMessageEncoder.java @@ -26,8 +26,7 @@ public final class GroupedRegionUpdateMessageEncoder extends MessageEncoder> updates = new HashMap<>(); - Map> snapshots = new HashMap<>(); + Map> encodes = new ConcurrentHashMap<>(); + Map> updates = new ConcurrentHashMap<>(); phaser.bulkRegister(playerCount); for (Player player : players) { - SynchronizationTask task = new PrePlayerSynchronizationTask(player, updates, snapshots); + SynchronizationTask task = new PrePlayerSynchronizationTask(player, encodes, updates); executor.submit(new PhasedSynchronizationTask(phaser, task)); } phaser.arriveAndAwaitAdvance(); diff --git a/game/src/main/org/apollo/game/sync/SequentialClientSynchronizer.java b/game/src/main/org/apollo/game/sync/SequentialClientSynchronizer.java index 0a69eb74..90e0a8c0 100644 --- a/game/src/main/org/apollo/game/sync/SequentialClientSynchronizer.java +++ b/game/src/main/org/apollo/game/sync/SequentialClientSynchronizer.java @@ -3,6 +3,7 @@ package org.apollo.game.sync; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.apollo.game.message.impl.RegionUpdateMessage; import org.apollo.game.model.area.RegionCoordinates; @@ -31,11 +32,10 @@ public final class SequentialClientSynchronizer extends ClientSynchronizer { @Override public void synchronize(MobRepository players, MobRepository npcs) { - Map> updates = new HashMap<>(); - Map> snapshots = new HashMap<>(); + Map> encodes = new HashMap<>(), updates = new HashMap<>(); for (Player player : players) { - SynchronizationTask task = new PrePlayerSynchronizationTask(player, updates, snapshots); + SynchronizationTask task = new PrePlayerSynchronizationTask(player, encodes, updates); task.run(); } diff --git a/game/src/main/org/apollo/game/sync/task/PrePlayerSynchronizationTask.java b/game/src/main/org/apollo/game/sync/task/PrePlayerSynchronizationTask.java index eedc64cb..0770115d 100644 --- a/game/src/main/org/apollo/game/sync/task/PrePlayerSynchronizationTask.java +++ b/game/src/main/org/apollo/game/sync/task/PrePlayerSynchronizationTask.java @@ -1,10 +1,7 @@ package org.apollo.game.sync.task; -import java.util.ArrayList; import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import org.apollo.game.message.impl.ClearRegionMessage; @@ -17,8 +14,6 @@ import org.apollo.game.model.area.RegionCoordinates; import org.apollo.game.model.area.RegionRepository; import org.apollo.game.model.entity.Player; -import com.google.common.collect.ImmutableSet; - /** * A {@link SynchronizationTask} which does pre-synchronization work for the specified {@link Player}. * @@ -28,62 +23,44 @@ import com.google.common.collect.ImmutableSet; public final class PrePlayerSynchronizationTask extends SynchronizationTask { /** - * The update mode used when sending a {@link GroupedRegionUpdateMessage}. + * The radius of viewable regions. */ - private enum RegionUpdateMode { - - /** - * The difference update mode, which only sends updates for changes that have occurred in the last pulse. - */ - DIFFERENCE, - - /** - * The full update mode, which sends everything in the Region. - */ - FULL; - - } - - /** - * The amount of Regions that are in view of a player, in one direction. - */ - private static final int REGION_COUNT = (int) Math.ceil(Position.MAX_DISTANCE / Region.SIZE); + private static final int VIEWABLE_REGION_RADIUS = 3; /** * The width of the viewport of every Player, in tiles. */ private static final int VIEWPORT_WIDTH = Region.SIZE * 13; + /** + * The Map of RegionCoordinates to Sets of RegionUpdateMessages, which contain all of the Entity information for a + * Region. + */ + private final Map> encodes; + /** * The player. */ private final Player player; - /** - * The Map of RegionCoordinates to Sets of RegionUpdateMessages, which contain all of the Entity information for a - * Region. - */ - private final Map> snapshots; - /** * The Map of RegionCoordinates to Sets of RegionUpdateMessages, which contain the updates for a Region a Player - * can - * already view. + * can already view. */ - private final Map> updates; + private final Map> updates; /** * Creates the {@link PrePlayerSynchronizationTask} for the specified {@link Player}. * * @param player The Player. + * @param encodes The Map containing Region encodes. * @param updates The {@link Map} containing {@link Region} updates. - * @param snapshots The Map containing Region snapshots. */ - public PrePlayerSynchronizationTask(Player player, Map> updates, - Map> snapshots) { + public PrePlayerSynchronizationTask(Player player, Map> encodes, + Map> updates) { this.player = player; this.updates = updates; - this.snapshots = snapshots; + this.encodes = encodes; } @Override @@ -91,71 +68,49 @@ public final class PrePlayerSynchronizationTask extends SynchronizationTask { Position old = player.getPosition(); player.getWalkingQueue().pulse(); - RegionUpdateMode mode = RegionUpdateMode.DIFFERENCE; - if (player.isTeleporting()) { player.resetViewingDistance(); - mode = RegionUpdateMode.FULL; } - boolean hasKnownRegion = player.hasLastKnownRegion(); - if (!hasKnownRegion) { - mode = RegionUpdateMode.FULL; - } - - if (!hasKnownRegion || isRegionUpdateRequired()) { + Position position = player.getPosition(); + if (!player.hasLastKnownRegion() || isRegionUpdateRequired()) { player.setRegionChanged(true); - Position position = player.getPosition(); player.setLastKnownRegion(position); player.send(new RegionChangeMessage(position)); } - Set newRegions = getNewRegions(old, player.getPosition()); - sendRegionUpdates(mode, newRegions); + Set oldViewable = getViewableRegions(old), newViewable = getViewableRegions(position); + + Set differences = new HashSet<>(newViewable); + differences.retainAll(oldViewable); + + Set full = new HashSet<>(newViewable); + full.removeAll(oldViewable); + + sendUpdates(player.getLastKnownRegion(), differences, full); } /** - * Gets the {@link Set} of {@link RegionCoordinates} of {@link Region}s that the {@link Player} in this task has - * only just became able to view. + * Gets the {@link Set} of {@link RegionCoordinates} of Regions that are viewable from the specified {@link + * Position}. * - * @param old The old {@link Position} of the Player. - * @param next The new Position of the Player. - * @return The Set of RegionCoordinates. Will not be {@code null}, but may be empty. + * @param position The Position. + * @return The Set of RegionCoordinates. */ - private Set getNewRegions(Position old, Position next) { - RegionCoordinates oldRegion = old.getRegionCoordinates(); - RegionCoordinates nextRegion = next.getRegionCoordinates(); + private Set getViewableRegions(Position position) { // TODO possibly more complicated than this + RegionCoordinates local = position.getRegionCoordinates(); + int localX = local.getX(), localY = local.getY(); + int maxX = localX + VIEWABLE_REGION_RADIUS, maxY = localY + VIEWABLE_REGION_RADIUS; - if (oldRegion.equals(nextRegion)) { - return ImmutableSet.of(); - } - - Set coordinates = new HashSet<>(9); - int oldX = oldRegion.getX(), oldY = oldRegion.getY(); - - int dx = nextRegion.getX() - oldX; - int dy = nextRegion.getY() - oldY; - - if (dx != 0) { - int x = oldX + dx; - int maxY = oldY + REGION_COUNT; - - for (int y = oldY - REGION_COUNT; y <= maxY; y++) { - coordinates.add(new RegionCoordinates(x, y)); + Set viewable = new HashSet<>(); + for (int x = localX - VIEWABLE_REGION_RADIUS; x < maxX; x++) { + for (int y = localY - VIEWABLE_REGION_RADIUS; y < maxY; y++) { + viewable.add(new RegionCoordinates(x, y)); } } - if (dy != 0) { - int y = oldY + dy; - int maxX = oldX + REGION_COUNT; - - for (int x = oldX - REGION_COUNT; x <= maxX; x++) { - coordinates.add(new RegionCoordinates(x, y)); - } - } - - return coordinates; + return viewable; } /** @@ -175,83 +130,30 @@ public final class PrePlayerSynchronizationTask extends SynchronizationTask { } /** - * Gets the {@link List} of {@link GroupedRegionUpdateMessage}s. + * Sends the updates for a {@link Region} * - * @param mode The {@link RegionUpdateMode} used when creating the Messages. - * @param newRegions The {@link Set} of {@link RegionCoordinates} that should be sent as a full update. + * @param position The {@link Position} of the last known region. + * @param differences The {@link Set} of {@link RegionCoordinates} of Regions that changed. + * @param full The {@link Set} of {@link RegionCoordinates} of Regions that require a full update. */ - private void sendRegionUpdates(RegionUpdateMode mode, Set newRegions) { - Position position = player.getPosition(); - RegionCoordinates base = position.getRegionCoordinates(); - int baseX = base.getX(), baseY = base.getY(); - + private void sendUpdates(Position position, Set differences, Set full) { RegionRepository repository = player.getWorld().getRegionRepository(); - List messages = new ArrayList<>(); + int height = position.getHeight(); - for (int x = baseX - REGION_COUNT; x <= baseX + REGION_COUNT; x++) { - for (int y = baseY - REGION_COUNT; y <= baseY + REGION_COUNT; y++) { - RegionCoordinates coordinates = new RegionCoordinates(x, y); + differences.stream().map(coordinates -> { + Set messages = updates.computeIfAbsent(coordinates, + coords -> repository.get(coords).getUpdates(height)); - RegionUpdateMode local = mode; - if (mode == RegionUpdateMode.DIFFERENCE && newRegions.contains(coordinates)) { - local = RegionUpdateMode.FULL; + return new GroupedRegionUpdateMessage(position, coordinates, messages); + }).forEach(player::send); - player.send(new ClearRegionMessage(position, coordinates)); - } + full.stream().map(coordinates -> { + Set messages = encodes.computeIfAbsent(coordinates, + coords -> repository.get(coords).encode(height)); - toUpdateMessage(local, player.getLastKnownRegion(), coordinates, repository).ifPresent(messages::add); - } - } - - messages.forEach(player::send); - } - - /** - * Creates a {@link GroupedRegionUpdateMessage} using the specified {@link RegionUpdateMode}, returning - * {@link Optional#empty()} if no update message is required. - * - * @param mode The RegionUpdateMode for the Message. - * @param lastKnownRegion The last known region {@link Position} of the Player. - * @param coordinates The {@link RegionCoordinates} of the {@link Region}. - * @param repository The {@link RegionRepository} containing the Regions. - * @return The Optional containing the GroupedRegionUpdateMessage. - */ - private Optional toUpdateMessage(RegionUpdateMode mode, Position lastKnownRegion, - RegionCoordinates coordinates, RegionRepository repository) { - List messages; - - /* - * Here we used Map#computeIfAbsent because the value may have been inserted into the Map by another thread - * after our Map#get call. This is done in two separate parts (rather than acquiring the lock every time, and - * just calling Map#computeIfAbsent immediately) for performance - once the List has been - * placed into the Map once, it will never be changed, and is therefore a read-only operation after this. The - * alternative, acquiring the lock for every access, would be very slow. - */ - - switch (mode) { - case DIFFERENCE: - messages = updates.get(coordinates); - if (messages == null) { - synchronized (updates) { - messages = updates.computeIfAbsent(coordinates, coords -> repository.get(coords).getUpdates(lastKnownRegion.getHeight())); - } - } - - break; - case FULL: - messages = snapshots.get(coordinates); - if (messages == null) { - synchronized (snapshots) { - messages = snapshots.computeIfAbsent(coordinates, coords -> repository.get(coords).getSnapshot(lastKnownRegion.getHeight())); - } - } - - break; - default: - throw new IllegalArgumentException("Unrecognised RegionUpdateMode " + mode + "."); - } - - return messages.isEmpty() ? Optional.empty() : Optional.of(new GroupedRegionUpdateMessage(lastKnownRegion, coordinates, messages)); + player.send(new ClearRegionMessage(position, coordinates)); + return new GroupedRegionUpdateMessage(position, coordinates, messages); + }).forEach(player::send); } } \ No newline at end of file