me.ryanhamshire.PopulationDensity.DataStore.java Source code

Java tutorial

Introduction

Here is the source code for me.ryanhamshire.PopulationDensity.DataStore.java

Source

/*
PopulationDensity Server Plugin for Minecraft
Copyright (C) 2011 Ryan Hamshire
    
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
    
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
    
You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package me.ryanhamshire.PopulationDensity;

import com.google.common.collect.ImmutableList;
import com.google.common.io.Files;
import org.apache.commons.lang.Validate;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.Sign;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.util.StringUtil;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

public class DataStore implements TabCompleter {
    //in-memory cache for player home region, because it's needed very frequently
    private HashMap<String, PlayerData> playerNameToPlayerDataMap = new HashMap<String, PlayerData>();

    //path information, for where stuff stored on disk is well...  stored
    private final static String dataLayerFolderPath = "plugins" + File.separator + "PopulationDensityData";
    private final static String playerDataFolderPath = dataLayerFolderPath + File.separator + "PlayerData";
    private final static String regionDataFolderPath = dataLayerFolderPath + File.separator + "RegionData";
    public final static String configFilePath = dataLayerFolderPath + File.separator + "config.yml";
    final static String messagesFilePath = dataLayerFolderPath + File.separator + "messages.yml";

    //in-memory cache for messages
    private String[] messages;

    //currently open region
    private RegionCoordinates openRegionCoordinates;

    //coordinates of the next region which will be opened, if one needs to be opened
    private RegionCoordinates nextRegionCoordinates;

    //region data cache
    private ConcurrentHashMap<String, RegionCoordinates> nameToCoordsMap = new ConcurrentHashMap<String, RegionCoordinates>();
    private ConcurrentHashMap<RegionCoordinates, String> coordsToNameMap = new ConcurrentHashMap<RegionCoordinates, String>();

    //initialization!
    public DataStore(List<String> regionNames) {
        //ensure data folders exist
        new File(playerDataFolderPath).mkdirs();
        new File(regionDataFolderPath).mkdirs();

        this.regionNamesList = regionNames.toArray(new String[] {});

        this.loadMessages();

        //get a list of all the files in the region data folder
        //some of them are named after region names, others region coordinates
        File regionDataFolder = new File(regionDataFolderPath);
        File[] files = regionDataFolder.listFiles();

        for (int i = 0; i < files.length; i++) {
            if (files[i].isFile()) //avoid any folders
            {
                try {
                    //if the filename converts to region coordinates, add that region to the list of defined regions
                    //(this constructor throws an exception if it can't do the conversion)
                    RegionCoordinates regionCoordinates = new RegionCoordinates(files[i].getName());
                    String regionName = Files.readFirstLine(files[i], Charset.forName("UTF-8"));
                    this.nameToCoordsMap.put(regionName.toLowerCase(), regionCoordinates);
                    this.coordsToNameMap.put(regionCoordinates, regionName);
                }

                //catch for files named after region names
                catch (Exception e) {
                }
            }
        }

        //study region data and initialize both this.openRegionCoordinates and this.nextRegionCoordinates
        this.findNextRegion();

        //if no regions were loaded, create the first one
        if (nameToCoordsMap.keySet().size() == 0) {
            PopulationDensity.AddLogEntry("Please be patient while I search for a good new player starting point!");
            PopulationDensity.AddLogEntry(
                    "This initial scan could take a while, especially for worlds where players have already been building.");
            this.addRegion();
        }

        PopulationDensity.AddLogEntry("Open region: \"" + this.getRegionName(this.getOpenRegion()) + "\" at "
                + this.getOpenRegion().toString() + ".");
    }

    //used in the spiraling code below (see findNextRegion())
    private enum Direction {
        left, right, up, down
    }

    //starts at region 0,0 and spirals outward until it finds a region which hasn't been initialized
    //sets private variables for openRegion and nextRegion when it's done
    //this may look like black magic, but seriously, it produces a tight spiral on a grid
    //coding this made me reminisce about seemingly pointless computer science exercises in college
    public int findNextRegion() {
        //spiral out from region coordinates 0, 0 until we find coordinates for an uninitialized region
        int x = 0;
        int z = 0;

        //keep count of the regions encountered
        int regionCount = 0;

        //initialization
        Direction direction = Direction.down; //direction to search
        int sideLength = 1; //maximum number of regions to move in this direction before changing directions
        int side = 0; //increments each time we change directions.  this tells us when to add length to each side
        this.openRegionCoordinates = new RegionCoordinates(0, 0);
        this.nextRegionCoordinates = new RegionCoordinates(0, 0);

        //while the next region coordinates are taken, walk the spiral
        while (this.getRegionName(this.nextRegionCoordinates) != null) {
            //loop for one side of the spiral
            for (int i = 0; i < sideLength && this.getRegionName(this.nextRegionCoordinates) != null; i++) {
                regionCount++;

                //converts a direction to a change in X or Z
                if (direction == Direction.down)
                    z++;
                else if (direction == Direction.left)
                    x--;
                else if (direction == Direction.up)
                    z--;
                else
                    x++;

                this.openRegionCoordinates = this.nextRegionCoordinates;
                this.nextRegionCoordinates = new RegionCoordinates(x, z);
            }

            //after finishing a side, change directions
            if (direction == Direction.down)
                direction = Direction.left;
            else if (direction == Direction.left)
                direction = Direction.up;
            else if (direction == Direction.up)
                direction = Direction.right;
            else
                direction = Direction.down;

            //keep count of the completed sides
            side++;

            //on even-numbered sides starting with side == 2, increase the length of each side
            if (side % 2 == 0)
                sideLength++;
        }

        //return total number of regions seen
        return regionCount;
    }

    //picks a region at random (sort of)
    public RegionCoordinates getRandomRegion(RegionCoordinates regionToAvoid) {
        if (this.coordsToNameMap.keySet().size() < 2)
            return null;

        //initialize random number generator with a seed based the current time
        Random randomGenerator = new Random();

        ArrayList<RegionCoordinates> possibleDestinations = new ArrayList<RegionCoordinates>();
        for (RegionCoordinates coords : this.coordsToNameMap.keySet()) {
            if (!coords.equals(regionToAvoid)) {
                possibleDestinations.add(coords);
            }
        }

        //pick one of those regions at random
        int randomRegion = randomGenerator.nextInt(possibleDestinations.size());
        return possibleDestinations.get(randomRegion);
    }

    public void savePlayerData(OfflinePlayer player, PlayerData data) {
        //save that data in memory
        this.playerNameToPlayerDataMap.put(player.getUniqueId().toString(), data);

        BufferedWriter outStream = null;
        try {
            //open the player's file
            File playerFile = new File(playerDataFolderPath + File.separator + player.getUniqueId().toString());
            playerFile.createNewFile();
            outStream = new BufferedWriter(new FileWriter(playerFile));

            //first line is home region coordinates
            outStream.write(data.homeRegion.toString());
            outStream.newLine();

            //second line is last disconnection date,
            //note use of the ROOT locale to avoid problems related to regional settings on the server being updated
            DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL, Locale.ROOT);
            outStream.write(dateFormat.format(data.lastDisconnect));
            outStream.newLine();

            //third line is login priority
            outStream.write(String.valueOf(data.loginPriority));
            outStream.newLine();
        }

        //if any problem, log it
        catch (Exception e) {
            PopulationDensity.AddLogEntry("PopulationDensity: Unexpected exception saving data for player \""
                    + player.getName() + "\": " + e.getMessage());
        }

        try {
            //close the file
            if (outStream != null)
                outStream.close();
        } catch (IOException exception) {
        }
    }

    public PlayerData getPlayerData(OfflinePlayer player) {
        //first, check the in-memory cache
        PlayerData data = this.playerNameToPlayerDataMap.get(player.getUniqueId().toString());

        if (data != null)
            return data;

        //if not there, try to load the player from file using UUID
        loadPlayerDataFromFile(player.getUniqueId().toString(), player.getUniqueId().toString());

        //check again
        data = this.playerNameToPlayerDataMap.get(player.getUniqueId().toString());

        if (data != null)
            return data;

        //if still not there, try player name
        loadPlayerDataFromFile(player.getName(), player.getUniqueId().toString());

        //check again
        data = this.playerNameToPlayerDataMap.get(player.getUniqueId().toString());

        if (data != null)
            return data;

        return new PlayerData();
    }

    private void loadPlayerDataFromFile(String source, String dest) {
        //load player data into memory
        File playerFile = new File(playerDataFolderPath + File.separator + source);

        BufferedReader inStream = null;
        try {
            PlayerData playerData = new PlayerData();
            inStream = new BufferedReader(new FileReader(playerFile.getAbsolutePath()));

            //first line is home region coordinates
            String homeRegionCoordinatesString = inStream.readLine();

            //second line is date of last disconnection
            String lastDisconnectedString = inStream.readLine();

            //third line is login priority
            String rankString = inStream.readLine();

            //convert string representation of home coordinates to a proper object
            RegionCoordinates homeRegionCoordinates = new RegionCoordinates(homeRegionCoordinatesString);
            playerData.homeRegion = homeRegionCoordinates;

            //parse the last disconnect date string
            try {
                DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL,
                        Locale.ROOT);
                Date lastDisconnect = dateFormat.parse(lastDisconnectedString);
                playerData.lastDisconnect = lastDisconnect;
            } catch (Exception e) {
                playerData.lastDisconnect = Calendar.getInstance().getTime();
            }

            //parse priority string
            if (rankString == null || rankString.isEmpty()) {
                playerData.loginPriority = 0;
            } else {
                try {
                    playerData.loginPriority = Integer.parseInt(rankString);
                } catch (Exception e) {
                    playerData.loginPriority = 0;
                }
            }

            //shove into memory for quick access
            this.playerNameToPlayerDataMap.put(dest, playerData);
        }

        //if the file isn't found, just don't do anything (probably a new-to-server player)
        catch (FileNotFoundException e) {
            return;
        }

        //if there's any problem with the file's content, log an error message and skip it
        catch (Exception e) {
            PopulationDensity.AddLogEntry("Unable to load data for player \"" + source + "\": " + e.getMessage());
        }

        try {
            if (inStream != null)
                inStream.close();
        } catch (IOException exception) {
        }
    }

    //adds a new region, assigning it a name and updating local variables accordingly
    public RegionCoordinates addRegion() {
        //first, find a unique name for the new region
        String newRegionName;

        //select a name from the list of region names
        //strategy: use names from the list in rotation, appending a number when a name is already used
        //(redstone, mountain, valley, redstone1, mountain1, valley1, ...)

        int newRegionNumber = this.coordsToNameMap.keySet().size() - 1;

        //as long as the generated name is already in use, move up one name on the list
        do {
            newRegionNumber++;
            int nameBodyIndex = newRegionNumber % this.regionNamesList.length;
            int nameSuffix = newRegionNumber / this.regionNamesList.length;
            newRegionName = this.regionNamesList[nameBodyIndex];
            if (nameSuffix > 0)
                newRegionName += nameSuffix;

        } while (this.getRegionCoordinates(newRegionName) != null);

        this.privateNameRegion(this.nextRegionCoordinates, newRegionName);

        //find the next region in the spiral (updates this.openRegionCoordinates and this.nextRegionCoordinates)
        this.findNextRegion();

        return this.openRegionCoordinates;
    }

    //names a region, never throws an exception for name content
    private void privateNameRegion(RegionCoordinates coords, String name) {
        //delete any existing data for the region at these coordinates
        String oldRegionName = this.getRegionName(coords);
        if (oldRegionName != null) {
            File oldRegionCoordinatesFile = new File(regionDataFolderPath + File.separator + coords.toString());
            oldRegionCoordinatesFile.delete();

            File oldRegionNameFile = new File(regionDataFolderPath + File.separator + oldRegionName);
            oldRegionNameFile.delete();
            this.coordsToNameMap.remove(coords);
            this.nameToCoordsMap.remove(oldRegionName.toLowerCase());
        }

        //"create" the region by saving necessary data to disk
        BufferedWriter outStream = null;
        try {
            //coordinates file contains the region's name
            File regionCoordinatesFile = new File(regionDataFolderPath + File.separator + coords.toString());
            regionCoordinatesFile.createNewFile();
            outStream = new BufferedWriter(new FileWriter(regionCoordinatesFile));
            outStream.write(name);
            outStream.close();

            //cache in memory
            this.coordsToNameMap.put(coords, name);
            this.nameToCoordsMap.put(name.toLowerCase(), coords);
        }

        //in case of any problem, log the details
        catch (Exception e) {
            PopulationDensity.AddLogEntry("Unexpected Exception: " + e.getMessage());
        }

        try {
            if (outStream != null)
                outStream.close();
        } catch (IOException exception) {
        }
    }

    //names or renames a specified region
    public void nameRegion(RegionCoordinates coords, String name) throws RegionNameException {
        //validate name
        String error = PopulationDensity.instance.getRegionNameError(name, false);
        if (error != null) {
            throw new RegionNameException(error);
        }

        this.privateNameRegion(coords, name);
    }

    //retrieves the open region's coordinates
    public RegionCoordinates getOpenRegion() {
        return this.openRegionCoordinates;
    }

    //goes to disk to get the name of a region, given its coordinates
    public String getRegionName(RegionCoordinates coordinates) {
        return this.coordsToNameMap.get(coordinates);
    }

    //similar to above, goes to disk to get the coordinates that go with a region name
    public RegionCoordinates getRegionCoordinates(String regionName) {
        return this.nameToCoordsMap.get(regionName.toLowerCase());
    }

    //actually edits the world to create a region post at the center of the specified region
    public void AddRegionPost(RegionCoordinates region) throws ChunkLoadException {
        //if region post building is disabled, don't do anything
        if (!PopulationDensity.instance.buildRegionPosts)
            return;

        //find the center
        Location regionCenter = PopulationDensity.getRegionCenter(region, false);
        int x = regionCenter.getBlockX();
        int z = regionCenter.getBlockZ();
        int y;

        //make sure data is loaded for that area, because we're about to request data about specific blocks there
        PopulationDensity.GuaranteeChunkLoaded(x, z);

        //sink lower until we find something solid
        //also ignore glowstone, in case there's already a post here!
        Material blockType;

        //find the highest block.  could be the surface, a tree, some grass...
        y = PopulationDensity.ManagedWorld.getHighestBlockYAt(x, z) + 1;

        //posts fall through trees, snow, and any existing post looking for the ground
        do {
            blockType = PopulationDensity.ManagedWorld.getBlockAt(x, --y, z).getType();
        } while (y > 0 && (blockType == Material.AIR || blockType == Material.OAK_LEAVES
                || blockType == Material.SPRUCE_LEAVES || blockType == Material.BIRCH_LEAVES
                || blockType == Material.JUNGLE_LEAVES || blockType == Material.ACACIA_LEAVES
                || blockType == Material.DARK_OAK_LEAVES || blockType == Material.GRASS
                || blockType == Material.TALL_GRASS || blockType == Material.OAK_LOG
                || blockType == Material.SPRUCE_LOG || blockType == Material.BIRCH_LOG
                || blockType == Material.JUNGLE_LOG || blockType == Material.ACACIA_LOG
                || blockType == Material.DARK_OAK_LOG || blockType == Material.SNOW || blockType == Material.VINE));

        if (blockType == Material.SIGN) {
            y -= 4;
        }
        // Changed check because of the refactor to the postdesign.
        else if (blockType == PopulationDensity.instance.postMaterialMidTop) {
            y -= 2;
        } else if (blockType == Material.BEDROCK) {
            y += 1;
        }

        //if y value is under sea level, correct it to sea level (no posts should be that difficult to find)
        if (y < PopulationDensity.instance.minimumRegionPostY) {
            y = PopulationDensity.instance.minimumRegionPostY;
        }

        //clear signs from the area, this ensures signs don't drop as items
        //when the blocks they're attached to are destroyed in the next step
        for (int x1 = x - 2; x1 <= x + 2; x1++) {
            for (int z1 = z - 2; z1 <= z + 2; z1++) {
                for (int y1 = y + 1; y1 <= y + 5; y1++) {
                    Block block = PopulationDensity.ManagedWorld.getBlockAt(x1, y1, z1);
                    if (block.getType() == Material.SIGN || block.getType() == Material.WALL_SIGN)
                        block.setType(Material.AIR);
                }
            }
        }

        //clear above it - sometimes this shears trees in half (doh!)
        for (int x1 = x - 2; x1 <= x + 2; x1++) {
            for (int z1 = z - 2; z1 <= z + 2; z1++) {
                for (int y1 = y + 1; y1 < y + 10; y1++) {
                    Block block = PopulationDensity.ManagedWorld.getBlockAt(x1, y1, z1);
                    if (block.getType() != Material.AIR)
                        block.setType(Material.AIR);
                }
            }
        }

        //Sometimes we don't clear high enough thanks to new ultra tall trees in jungle biomes
        //Instead of attempting to clear up to nearly 110 * 4 blocks more, we'll just see what getHighestBlockYAt returns
        //If it doesn't return our post's y location, we're setting it and all blocks below to air.
        int highestBlockY = PopulationDensity.ManagedWorld.getHighestBlockYAt(x, z);
        while (highestBlockY > y) {
            Block block = PopulationDensity.ManagedWorld.getBlockAt(x, highestBlockY, z);
            if (block.getType() != Material.AIR)
                block.setType(Material.AIR);
            highestBlockY--;
        }

        //build post
        PopulationDensity.ManagedWorld.getBlockAt(x, y + 3, z).setType(PopulationDensity.instance.postMaterialTop);
        PopulationDensity.ManagedWorld.getBlockAt(x, y + 2, z)
                .setType(PopulationDensity.instance.postMaterialMidTop);
        PopulationDensity.ManagedWorld.getBlockAt(x, y + 1, z)
                .setType(PopulationDensity.instance.postMaterialMidBottom);
        PopulationDensity.ManagedWorld.getBlockAt(x, y, z).setType(PopulationDensity.instance.postMaterialBottom);

        //build outer platform
        for (int x1 = x - 2; x1 <= x + 2; x1++) {
            for (int z1 = z - 2; z1 <= z + 2; z1++) {
                PopulationDensity.ManagedWorld.getBlockAt(x1, y, z1)
                        .setType(PopulationDensity.instance.outerPlatformMaterial);
            }
        }

        //build inner platform
        for (int x1 = x - 1; x1 <= x + 1; x1++) {
            for (int z1 = z - 1; z1 <= z + 1; z1++) {
                PopulationDensity.ManagedWorld.getBlockAt(x1, y, z1)
                        .setType(PopulationDensity.instance.innerPlatformMaterial);
            }
        }

        String regionName = this.getRegionName(region);

        //If the top sign configuration is not null
        if (PopulationDensity.instance.topSignContent != null) {
            //build a sign on top with region name (or wilderness if no name)
            if (regionName == null)
                regionName = getMessage(Messages.Wilderness);
            regionName = PopulationDensity.capitalize(regionName);
            setSign(x, y + 4, z, BlockFace.NORTH, PopulationDensity.instance.topSignContent, "%regionName%",
                    regionName);
        }

        //If the side sign configuration is not null
        if (PopulationDensity.instance.sideSignContent != null) {
            //add a sign for the region to the north
            regionName = this.getRegionName(new RegionCoordinates(region.x - 1, region.z));
            if (regionName == null)
                regionName = getMessage(Messages.Wilderness);
            regionName = PopulationDensity.capitalize(regionName);
            setWallSign(x, y + 2, z + 1, BlockFace.SOUTH, PopulationDensity.instance.sideSignContent,
                    "%regionName%", regionName);

            //add a sign for the region to the east
            regionName = this.getRegionName(new RegionCoordinates(region.x, region.z - 1));
            if (regionName == null)
                regionName = getMessage(Messages.Wilderness);
            regionName = PopulationDensity.capitalize(regionName);
            setWallSign(x - 1, y + 2, z, BlockFace.WEST, PopulationDensity.instance.sideSignContent, "%regionName%",
                    regionName);

            //add a sign for the region to the south
            regionName = this.getRegionName(new RegionCoordinates(region.x + 1, region.z));
            if (regionName == null)
                regionName = getMessage(Messages.Wilderness);
            regionName = PopulationDensity.capitalize(regionName);
            setWallSign(x, y + 2, z - 1, BlockFace.NORTH, PopulationDensity.instance.sideSignContent,
                    "%regionName%", regionName);

            //add a sign for the region to the west
            regionName = this.getRegionName(new RegionCoordinates(region.x, region.z + 1));
            if (regionName == null)
                regionName = getMessage(Messages.Wilderness);
            regionName = PopulationDensity.capitalize(regionName);
            setWallSign(x + 1, y + 2, z, BlockFace.EAST, PopulationDensity.instance.sideSignContent, "%regionName%",
                    regionName);
        }

        //if teleportation is enabled, also add a sign facing north and south for teleportation help
        if (PopulationDensity.instance.allowTeleportation) {
            if (PopulationDensity.instance.instructionsSignContent != null) {
                setWallSign(x - 1, y + 3, z, BlockFace.WEST, PopulationDensity.instance.instructionsSignContent);
                setWallSign(x + 1, y + 3, z, BlockFace.EAST, PopulationDensity.instance.instructionsSignContent);
            }
        }

        //custom signs
        if (PopulationDensity.instance.mainCustomSignContent != null) {
            setWallSign(x, y + 3, z - 1, BlockFace.NORTH, PopulationDensity.instance.mainCustomSignContent);
        }

        if (PopulationDensity.instance.northCustomSignContent != null) {
            setWallSign(x - 1, y + 1, z, BlockFace.WEST, PopulationDensity.instance.northCustomSignContent);
        }

        if (PopulationDensity.instance.southCustomSignContent != null) {
            setWallSign(x + 1, y + 1, z, BlockFace.EAST, PopulationDensity.instance.southCustomSignContent);
        }

        if (PopulationDensity.instance.eastCustomSignContent != null) {
            setWallSign(x, y + 1, z - 1, BlockFace.NORTH, PopulationDensity.instance.eastCustomSignContent);
        }

        if (PopulationDensity.instance.westCustomSignContent != null) {
            setWallSign(x, y + 1, z + 1, BlockFace.SOUTH, PopulationDensity.instance.westCustomSignContent);
        }
    }

    private void setSign(int x, int y, int z, BlockFace blockFace, String[] lines, String... replacements) {
        Block block = PopulationDensity.ManagedWorld.getBlockAt(x, y, z);
        block.setType(Material.SIGN);

        org.bukkit.block.data.type.Sign wall = (org.bukkit.block.data.type.Sign) block.getBlockData();
        wall.setRotation(blockFace);
        block.setBlockData(wall);

        Sign s = (Sign) block.getState();
        for (int i = 0; i < 4; i++) {
            String line = lines[i];
            for (int r = 0; r < replacements.length; r++, r++) {
                String key = replacements[r];
                String value = replacements[r + 1];
                line = line.replace(key, value);
            }
            s.setLine(i, color(line));
        }
        s.update();
    }

    private void setWallSign(int x, int y, int z, BlockFace blockFace, String[] lines, String... replacements) {
        Block block = PopulationDensity.ManagedWorld.getBlockAt(x, y, z);
        block.setType(Material.WALL_SIGN);

        org.bukkit.block.data.type.WallSign wall = (org.bukkit.block.data.type.WallSign) block.getBlockData();
        wall.setFacing(blockFace);
        block.setBlockData(wall);

        Sign s = (Sign) block.getState();
        for (int i = 0; i < 4; i++) {
            String line = lines[i];
            for (int r = 0; r < replacements.length; r++, r++) {
                String key = replacements[r];
                String value = replacements[r + 1];
                line = line.replace(key, value);
            }
            s.setLine(i, color(line));
        }
        s.update();
    }

    private String color(String string) {
        return ChatColor.translateAlternateColorCodes('&', string);
    }

    public void clearCachedPlayerData(Player player) {
        this.playerNameToPlayerDataMap.remove(player.getName());
    }

    private void loadMessages() {
        Messages[] messageIDs = Messages.values();
        this.messages = new String[Messages.values().length];

        HashMap<String, CustomizableMessage> defaults = new HashMap<String, CustomizableMessage>();

        //initialize defaults
        this.addDefault(defaults, Messages.NoManagedWorld,
                "The PopulationDensity plugin has not been properly configured.  Please update your config.yml to specify a world to manage.",
                null);
        this.addDefault(defaults, Messages.NoBreakPost, "You can't break blocks this close to the region post.",
                null);
        this.addDefault(defaults, Messages.NoBreakSpawn,
                "You can't break blocks this close to a player spawn point.", null);
        this.addDefault(defaults, Messages.NoBuildPost, "You can't place blocks this close to the region post.",
                null);
        this.addDefault(defaults, Messages.NoBuildSpawn,
                "You can't place blocks this close to a player spawn point.", null);
        this.addDefault(defaults, Messages.HelpMessage1, "Region post help and commands: {0} ", "0: Help URL");
        this.addDefault(defaults, Messages.BuildingAwayFromHome,
                "You're building outside of your home region.  If you'd like to make this region your new home to help you return here later, use /MoveIn.",
                null);
        this.addDefault(defaults, Messages.NoTeleportThisWorld, "You can't teleport from this world.", null);
        this.addDefault(defaults, Messages.OnlyHomeCityHere, "You're limited to /HomeRegion and /CityRegion here.",
                null);
        this.addDefault(defaults, Messages.NoTeleportHere, "Sorry, you can't teleport from here.", null);
        this.addDefault(defaults, Messages.NotCloseToPost, "You're not close enough to a region post to teleport.",
                null);
        this.addDefault(defaults, Messages.InvitationNeeded,
                "{0} lives in the wilderness.  He or she will have to /invite you.", "0: target player");
        this.addDefault(defaults, Messages.VisitConfirmation, "Teleported to {0}'s home region.",
                "0: target player");
        this.addDefault(defaults, Messages.DestinationNotFound,
                "There's no region or online player named \"{0}\".  Use /ListRegions to list possible destinations.",
                "0: specified destination");
        this.addDefault(defaults, Messages.NeedNewestRegionPermission,
                "You don't have permission to use that command.", null);
        this.addDefault(defaults, Messages.NewestRegionConfirmation, "Teleported to the current new player area.",
                null);
        this.addDefault(defaults, Messages.NotInRegion, "You're not in a region!", null);
        this.addDefault(defaults, Messages.UnnamedRegion,
                "You're in the wilderness!  This region doesn't have a name.", null);
        this.addDefault(defaults, Messages.WhichRegion, "You're in the {0} region.", null);
        this.addDefault(defaults, Messages.RegionNamesNoSpaces, "Region names may not include spaces.", null);
        this.addDefault(defaults, Messages.RegionNameLength, "Region names must be at most {0} letters long.",
                "0: maximum length specified in config.yml");
        this.addDefault(defaults, Messages.RegionNamesOnlyLettersAndNumbers,
                "Region names may not include symbols or punctuation.", null);
        this.addDefault(defaults, Messages.RegionNameConflict, "There's already a region by that name.", null);
        this.addDefault(defaults, Messages.NoMoreRegions,
                "Sorry, you're in the only region.  Over time, more regions will open.", null);
        this.addDefault(defaults, Messages.InviteAlreadySent,
                "{0} may now use /visit {1} to teleport to your home post.",
                "0: invitee's name, 1: inviter's name");
        this.addDefault(defaults, Messages.InviteConfirmation,
                "{0} may now use /visit {1} to teleport to your home post.",
                "0: invitee's name, 1: inviter's name");
        this.addDefault(defaults, Messages.InviteNotification, "{0} has invited you to visit!",
                "0: inviter's name");
        this.addDefault(defaults, Messages.InviteInstruction, "Use /visit {0} to teleport there.",
                "0: inviter's name");
        this.addDefault(defaults, Messages.PlayerNotFound, "There's no player named \"{0}\" online right now.",
                "0: specified name");
        this.addDefault(defaults, Messages.SetHomeConfirmation, "Home set to the nearest region post!", null);
        this.addDefault(defaults, Messages.SetHomeInstruction1,
                "Use /Home from any region post to teleport to your home post.", null);
        this.addDefault(defaults, Messages.SetHomeInstruction2,
                "Use /Invite to invite other players to teleport to your home post.", null);
        this.addDefault(defaults, Messages.AddRegionConfirmation,
                "Opened a new region and started a resource scan.  See console or server logs for details.", null);
        this.addDefault(defaults, Messages.ScanStartConfirmation,
                "Started scan.  Check console or server logs for results.", null);
        this.addDefault(defaults, Messages.LoginPriorityCheck, "{0}'s login priority: {1}.",
                "0: player name, 1: current priority");
        this.addDefault(defaults, Messages.LoginPriorityUpdate, "Set {0}'s priority to {1}.",
                "0: target player, 1: new priority");
        this.addDefault(defaults, Messages.ThinningConfirmation,
                "Thinning running.  Check logs for detailed results.", null);
        this.addDefault(defaults, Messages.PerformanceScore, "Current server performance score is {0}%.",
                "0: performance score");
        this.addDefault(defaults, Messages.PerformanceScore_Lag,
                "  The server is actively working to reduce lag - please be patient while automatic lag reduction takes effect.",
                null);
        this.addDefault(defaults, Messages.PerformanceScore_NoLag,
                "The server is running at normal speed.  If you're experiencing lag, check your graphics settings and internet connection.  ",
                null);
        this.addDefault(defaults, Messages.PlayerMoved, "Player moved.", null);
        this.addDefault(defaults, Messages.Lag, "lag", null);
        this.addDefault(defaults, Messages.RegionAlreadyNamed,
                "This region already has a name.  To REname, use /RenameRegion.", null);
        this.addDefault(defaults, Messages.HopperLimitReached,
                "To prevent server lag, hoppers are limited to {0} per chunk.", "0: maximum hoppers per chunk");
        this.addDefault(defaults, Messages.OutsideWorldBorder,
                "The region you are attempting to teleport to is outside the world border.", null);
        this.addDefault(defaults, Messages.Wilderness, "Wilderness", null);

        //load the config file
        FileConfiguration config = YamlConfiguration.loadConfiguration(new File(messagesFilePath));

        //for each message ID
        for (int i = 0; i < messageIDs.length; i++) {
            //get default for this message
            Messages messageID = messageIDs[i];
            CustomizableMessage messageData = defaults.get(messageID.name());

            //if default is missing, log an error and use some fake data for now so that the plugin can run
            if (messageData == null) {
                PopulationDensity.AddLogEntry(
                        "Missing message for " + messageID.name() + ".  Please contact the developer.");
                messageData = new CustomizableMessage(messageID,
                        "Missing message!  ID: " + messageID.name() + ".  Please contact a server admin.", null);
            }

            //read the message from the file, use default if necessary
            this.messages[messageID.ordinal()] = config.getString("Messages." + messageID.name() + ".Text",
                    messageData.text);
            config.set("Messages." + messageID.name() + ".Text", this.messages[messageID.ordinal()]);

            if (messageData.notes != null) {
                messageData.notes = config.getString("Messages." + messageID.name() + ".Notes", messageData.notes);
                config.set("Messages." + messageID.name() + ".Notes", messageData.notes);
            }
        }

        //save any changes
        try {
            config.save(DataStore.messagesFilePath);
        } catch (IOException exception) {
            PopulationDensity.AddLogEntry(
                    "Unable to write to the configuration file at \"" + DataStore.messagesFilePath + "\"");
        }

        defaults.clear();
        System.gc();
    }

    private void addDefault(HashMap<String, CustomizableMessage> defaults, Messages id, String text, String notes) {
        CustomizableMessage message = new CustomizableMessage(id, text, notes);
        defaults.put(id.name(), message);
    }

    synchronized public String getMessage(Messages messageID, String... args) {
        String message = messages[messageID.ordinal()];

        for (int i = 0; i < args.length; i++) {
            String param = args[i];
            message = message.replace("{" + i + "}", param);
        }

        return message;
    }

    //list of region names to use
    private String[] regionNamesList;

    String getRegionNames() {
        StringBuilder builder = new StringBuilder();
        for (String regionName : this.nameToCoordsMap.keySet()) {
            builder.append(PopulationDensity.capitalize(regionName)).append(", ");
        }

        return builder.toString();
    }

    @Override
    public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args)
            throws IllegalArgumentException {
        Validate.notNull(sender, "Sender cannot be null");
        Validate.notNull(args, "Arguments cannot be null");
        Validate.notNull(alias, "Alias cannot be null");
        if (args.length == 0) {
            return ImmutableList.of();
        }

        StringBuilder builder = new StringBuilder();
        for (String arg : args) {
            builder.append(arg + " ");
        }

        String arg = builder.toString().trim();
        ArrayList<String> matches = new ArrayList<String>();
        for (String name : this.coordsToNameMap.values()) {
            if (StringUtil.startsWithIgnoreCase(name, arg)) {
                matches.add(name);
            }
        }

        Player senderPlayer = sender instanceof Player ? (Player) sender : null;
        for (Player player : sender.getServer().getOnlinePlayers()) {
            if (senderPlayer == null || senderPlayer.canSee(player)) {
                if (StringUtil.startsWithIgnoreCase(player.getName(), arg)) {
                    matches.add(player.getName());
                }
            }
        }

        Collections.sort(matches, String.CASE_INSENSITIVE_ORDER);
        return matches;
    }
}