Package org.mctourney.autoreferee

Source Code of org.mctourney.autoreferee.AutoRefMatch$TranscriptEvent

package org.mctourney.autoreferee;

import java.awt.image.RenderedImage;
import java.io.*;
import java.lang.management.ManagementFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import javax.imageio.ImageIO;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.MapDifference;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.FileFilterUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.apache.commons.lang3.StringUtils;

import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.OfflinePlayer;
import org.bukkit.World;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Ambient;
import org.bukkit.entity.Animals;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ExperienceOrb;
import org.bukkit.entity.Item;
import org.bukkit.entity.Monster;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
import org.bukkit.material.Attachable;
import org.bukkit.material.Button;
import org.bukkit.material.Lever;
import org.bukkit.material.MaterialData;
import org.bukkit.material.PressurePlate;
import org.bukkit.material.PressureSensor;
import org.bukkit.material.Redstone;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;

import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.JDOMParseException;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;

import org.mctourney.autoreferee.event.match.MatchCompleteEvent;
import org.mctourney.autoreferee.event.match.MatchStartEvent;
import org.mctourney.autoreferee.event.match.MatchTranscriptEvent;
import org.mctourney.autoreferee.event.match.MatchUnloadEvent;
import org.mctourney.autoreferee.event.match.MatchUploadStatsEvent;
import org.mctourney.autoreferee.event.player.PlayerMatchJoinEvent;
import org.mctourney.autoreferee.event.player.PlayerMatchLeaveEvent;
import org.mctourney.autoreferee.event.player.PlayerTeamJoinEvent;
import org.mctourney.autoreferee.goals.AutoRefGoal;
import org.mctourney.autoreferee.goals.TimeGoal;
import org.mctourney.autoreferee.goals.scoreboard.AutoRefObjective;
import org.mctourney.autoreferee.listeners.GoalsInventorySnapshot;
import org.mctourney.autoreferee.listeners.SpectatorListener;
import org.mctourney.autoreferee.listeners.ZoneListener;
import org.mctourney.autoreferee.regions.AutoRefRegion;
import org.mctourney.autoreferee.regions.CuboidRegion;
import org.mctourney.autoreferee.util.ArmorPoints;
import org.mctourney.autoreferee.util.BlockData;
import org.mctourney.autoreferee.util.BookUtil;
import org.mctourney.autoreferee.util.LocationUtil;
import org.mctourney.autoreferee.util.MapImageGenerator;
import org.mctourney.autoreferee.util.Metadatable;
import org.mctourney.autoreferee.util.PlayerKit;
import org.mctourney.autoreferee.util.PlayerUtil;
import org.mctourney.autoreferee.util.QueryUtil;
import org.mctourney.autoreferee.util.ReportGenerator;
import org.mctourney.autoreferee.util.SportBukkitUtil;
import org.mctourney.autoreferee.util.TeleportationUtil;

/**
* Represents a game world controlled by AutoReferee.
*
* @author authorblues
*/
public class AutoRefMatch implements Metadatable
{
  // modify the internal NMS scoreboard instance with a custom scoreboard
  private static final boolean REPLACE_INTERNAL_SCOREBOARD = false;

  // online map list
  private static String MAPREPO = "http://autoreferee.s3.amazonaws.com/";

  /**
   * Get the base url for the map repository
   * @return url of map repository
   */
  public static String getMapRepo()
  { return MAPREPO; }

  /**
   * Sets a new map repository for the plugin to download maps
   * @param url url of new map repository to use
   */
  public static void changeMapRepo(String url)
  { MAPREPO = url + "/"; }

  // set this to false to not give match info books to players
  public static boolean giveMatchInfoBooks = true;

  static
  {
    File matchSummaryDirectory;

    // determine the location of the match-summary directory
    FileConfiguration config = AutoReferee.getInstance().getConfig();
    if (config.isString("local-storage.match-summary.directory"))
      matchSummaryDirectory = new File(config.getString("local-storage.match-summary.directory"));
    else matchSummaryDirectory = new File(AutoReferee.getInstance().getDataFolder(), "summary");

    // if the folder doesnt exist, create it...
    if (!matchSummaryDirectory.exists()) matchSummaryDirectory.mkdir();
  }

  protected Map<String, Object> metadata = Maps.newHashMap();

  public void addMetadata(String key, Object value)
  { this.metadata.put(key, value); }

  public Object getMetadata(String key)
  { return this.metadata.get(key); }

  public boolean hasMetadata(String key)
  { return this.metadata.containsKey(key); }

  public Object removeMetadata(String key)
  { return this.metadata.remove(key); }

  public void clearMetadata()
  { this.metadata.clear(); }

  public enum AccessType
  { PRIVATE, PUBLIC }

  public AccessType access = AccessType.PRIVATE;

  protected boolean currentlyTied = false;

  // world this match is taking place on
  private World primaryWorld;

  private AutoRefRegion worldSpawn = null;
  private AutoRefRegion specSpawn = null;

  private void setPrimaryWorld(World w)
  {
    primaryWorld = w;
    worldConfigFile = new File(w.getWorldFolder(), AutoReferee.CFG_FILENAME);
    setWorldSpawn(primaryWorld.getSpawnLocation());
  }

  public void setWorldSpawn(Location loc)
  {
    while (!TeleportationUtil.isBlockPassable(loc.getWorld().getBlockAt(loc))) loc = loc.add(0, 1, 0);
    worldSpawn = new org.mctourney.autoreferee.regions.PointRegion(loc);
    loc.getWorld().setSpawnLocation(loc.getBlockX(), loc.getBlockY(), loc.getBlockZ());
  }

  public void setSpectatorSpawn(Location loc)
  {
    while (!TeleportationUtil.isBlockPassable(loc.getWorld().getBlockAt(loc))) loc = loc.add(0, 1, 0);
    specSpawn = new org.mctourney.autoreferee.regions.PointRegion(loc);
  }

  public boolean isPracticeMode()
  {
    if (!this.getCurrentState().inProgress()) return false;

    int existingteams = 0;
    for (AutoRefTeam team : this.getTeams())
      if (!team.isEmptyTeam()) ++existingteams;
    return existingteams < 2;
  }

  protected boolean previewMode = false;

  public void setPreviewMode(boolean b)
  { this.previewMode = b; }

  public boolean isPreviewMode()
  { return this.previewMode; }

  /**
   * Gets the world associated with this match.
   *
   * @return world
   */
  public World getWorld()
  { return primaryWorld; }

  @Override public int hashCode()
  { return getWorld().hashCode(); }

  @Override public String toString()
  {
    return String.format("%s[%s, w=%s]", this.getClass().getSimpleName(),
      this.mapName, this.getWorld().getName());
  }

  /**
   * Gets the global spawn location for this match.
   *
   * @return global spawn location
   */
  public Location getWorldSpawn()
  { return worldSpawn.getLocation(); }

  /**
   * Gets the global spawn location for this match.
   *
   * @return global spawn location
   */
  public Location getSpectatorSpawn()
  { return specSpawn != null ? specSpawn.getLocation() : getWorldSpawn(); }

  private boolean tmp;

  public AutoRefMatch temporary()
  { this.tmp = true; return this; }

  public boolean isTemporaryWorld()
  { return tmp; }

  private long startClock = 0L;
  protected boolean lockTime = false;

  /**
   * Gets the time to set the world to at the start of the match.
   *
   * @return world time in ticks to be set at start of the match
   */
  public long getStartClock()
  { return startClock; }

  /**
   * Represents the status of a match.
   *
   * @author authorblues
   */
  public enum MatchStatus
  {
    /**
     * No match for this world.
     */
    NONE,

    /**
     * Waiting for players to join.
     */
    WAITING(5*60*1000L),

    /**
     * Players joined, waiting for match start.
     */
    READY(5*60*1000L),

    /**
     * Match in progress.
     */
    PLAYING(30*60*1000L),

    /**
     * Match completed.
     */
    COMPLETED(5*60*1000L);

    public long inactiveMillis;

    MatchStatus()
    { this(Long.MAX_VALUE); }

    MatchStatus(long ms)
    { this.inactiveMillis = ms; }

    /**
     * Checks if the match has not yet started.
     *
     * @return true if match has not started, otherwise false
     */
    public boolean isBeforeMatch()
    { return this.ordinal() < PLAYING.ordinal() && this != NONE; }

    /**
     * Checks if the match has completed.
     *
     * @return true if match is completed, otherwise false
     */
    public boolean isAfterMatch()
    { return this.ordinal() > PLAYING.ordinal() && this != NONE; }

    /**
     * Checks if match is in progress.
     *
     * @return true if match is in progress, otherwise false
     */
    public boolean inProgress()
    { return this == PLAYING; }
  }

  // status of the match
  private MatchStatus currentState = MatchStatus.NONE;

  /**
   * Gets the current status of this match.
   *
   * @return match status
   */
  public MatchStatus getCurrentState()
  { return currentState; }

  /**
   * Sets the current status of this match.
   *
   * @param status new match status
   */
  public void setCurrentState(MatchStatus status)
  { this.currentState = status; this.setupSpectators(); }

  // custom scoreboard
  protected final Scoreboard scoreboard;
  protected final Scoreboard  infoboard;
  protected Objective infoboardObjective;

  protected List<Objective> allInfoObjectives;

  public Scoreboard getScoreboard()
  { return scoreboard; }

  Scoreboard getInfoboard()
  { return infoboard; }

  // teams participating in the match
  protected Set<AutoRefTeam> teams = Sets.newHashSet();

  /**
   * Gets the teams participating in this match.
   *
   * @return set of teams
   */
  public Set<AutoRefTeam> getTeams()
  { return teams; }

  public String getTeamList()
  {
    Set<String> tlist = Sets.newHashSet();
    for (AutoRefTeam team : getTeams())
      tlist.add(team.getDisplayName());
    return StringUtils.join(tlist, ", ");
  }

  private AutoRefTeam winningTeam = null;

  /**
   * Gets the team that won this match.
   *
   * @return team that won the match if it is over, otherwise null
   */
  public AutoRefTeam getWinningTeam()
  { return winningTeam; }

  /**
   * Sets the team that won this match.
   */
  public void setWinningTeam(AutoRefTeam team)
  { winningTeam = team; }

  protected Map<String, PlayerKit> kits;

  public PlayerKit getKit(String name)
  { return kits.get(name); }

  // region defined as the "start" region (safe zone)
  private Set<AutoRefRegion> startRegions = Sets.newHashSet();

  /**
   * Gets the region designated as the start platform. This region should contain the
   * world spawn location. Players in this region are immune to damage from other players,
   * and mobs will not spawn in this region.
   *
   * @return start region
   */
  public Set<AutoRefRegion> getStartRegions()
  { return startRegions; }

  public void addStartRegion(AutoRefRegion reg)
  { this.startRegions.add(reg); }

  private Set<AutoRefRegion.Flag> startRegionFlags = Sets.newHashSet
  AutoRefRegion.Flag.NO_BUILD
  ,  AutoRefRegion.Flag.SAFE
  ,  AutoRefRegion.Flag.NO_EXPLOSIONS
  );

  public Set<AutoRefRegion.Flag> getStartRegionFlags()
  { return Collections.unmodifiableSet(startRegionFlags); }

  public double distanceToStartRegion(Location loc)
  {
    double dist = Double.MAX_VALUE;
    for (AutoRefRegion reg : startRegions)
    {
      double d = reg.distanceToRegion(loc);
      if (d < dist) dist = d;
    }

    return dist;
  }

  public CuboidRegion getMapCuboid()
  {
    CuboidRegion cube = null;
    for (AutoRefRegion reg : getStartRegions())
      cube = AutoRefRegion.combine(cube, reg);

    for (AutoRefTeam team : getTeams())
      for (AutoRefRegion reg : team.getRegions())
        cube = AutoRefRegion.combine(cube, reg);
    return cube;
  }

  // name of the match
  private String matchName = null;

  /**
   * Sets the custom name for this match.
   *
   * @param name custom match name
   */
  public void setMatchName(String name)
  { matchName = name; }

  /**
   * Gets the name of this match.
   *
   * @return match name
   */
  public String getMatchName()
  {
    // if we have a specific match name...
    if (matchName != null) return matchName;

    // generate a date string
    String date = new SimpleDateFormat("dd MMM yyyy").format(new Date());

    // if the map is named, return map name as a placeholder
    if (mapName != null) return mapName + ": " + date;

    // otherwise, just return the date
    return date;
  }

  // configuration information for the world
  protected File worldConfigFile;
  protected Element worldConfig;
  private boolean saveConfig = true;

  // basic variables loaded from file
  protected String mapName = null;
  protected Collection<String> mapAuthors = null;

  /**
   * Gets the name of the map for this match.
   *
   * @return map name
   */
  public String getMapName()
  { return mapName; }

  protected String versionString = "1.0";

  /**
   * Gets the version number of the map for this match.
   *
   * @return version number
   */
  public String getMapVersion()
  { return versionString; }

  /**
   * Gets the shorthand version string of the map for this match. This string will have the format
   * of "MapName-vX.Y"
   *
   * @return version string
   */
  public String getVersionString()
  { return String.format("%s-v%s", normalizeMapName(this.getMapName()), this.getMapVersion()); }

  public AutoRefMap getMap()
  { return AutoRefMap.getMap(mapName); }

  /**
   * Gets the creators of the map for this match.
   *
   * @return string list of names
   */
  public String getAuthorList()
  {
    if (mapAuthors != null && mapAuthors.size() != 0)
      return StringUtils.join(mapAuthors, ", ");
    return "??";
  }

  private long startTime = 0;

  public long getStartTime()
  { return startTime; }

  public void setStartTime(long time)
  { this.startTime = time; }

  /**
   * Gets the number of seconds elapsed in this match.
   *
   * @return current elapsed seconds if match in progress, otherwise 0L
   */
  public long recordedTime = 0L;
  public long getElapsedSeconds()
  {
    if (!getCurrentState().inProgress()) return recordedTime;
    return (ManagementFactory.getRuntimeMXBean().getUptime() - getStartTime()) / 1000L;
  }

  private long timeLimit = 0L;

  /**
   * Gets the match time limit in seconds.
   *
   * @return time limit in seconds
   */
  public long getTimeLimit()
  { return timeLimit; }

  /**
   * Checks if this match has a set time limit.
   *
   * @return true if a time limit is set, otherwise false
   */
  public boolean hasTimeLimit()
  { return timeLimit > 0L; }

  /**
   * Gets the number of seconds remaining in this match.
   *
   * @return time remaining in seconds
   */
  public long getTimeRemaining()
  { return timeLimit - getElapsedSeconds(); }

  /**
   * Sets match time limit in seconds.
   *
   * @param limit new time limit in seconds
   */
  public void setTimeLimit(long limit)
  { this.timeLimit = limit; }

  /**
   * Gets current match time, default value separator (colon).
   *
   * @return current match timestamp
   */
  public String getTimestamp()
  { return getTimestamp(":"); }

  /**
   * Gets current match time, with value separator.
   *
   * @param sep time value separator
   * @return current match timestamp
   */
  public String getTimestamp(String sep)
  {
    long timestamp = this.getElapsedSeconds();
    return String.format("%02d%s%02d%s%02d", timestamp/3600L,
      sep, (timestamp/60L)%60L, sep, timestamp%60L);
  }

  // task that starts the match
  protected CountdownTask matchStarter = null;

  // mechanisms to open the starting gates
  protected Set<StartMechanism> startMechanisms;

  // protected entities - only protected from "butchering"
  private Set<UUID> protectedEntities;

  public boolean isProtected(UUID uuid)
  { return protectedEntities.contains(uuid); }

  public void protect(UUID uuid)
  { protectedEntities.add(uuid); }

  public void unprotect(UUID uuid)
  { protectedEntities.remove(uuid); }

  public void toggleProtection(UUID uuid)
  { if (isProtected(uuid)) unprotect(uuid); else protect(uuid); }

  protected boolean playersBecomeSpectators = true;

  protected boolean allowFriendlyFire = true;

  /**
   * Checks if friendly fire is allowed in this match.
   *
   * @return true if friendly fire is allowed, otherwise false
   */
  public boolean allowFriendlyFire()
  { return allowFriendlyFire; }

  /**
   * Sets whether friendly fire is allowed in this match.
   */
  public void setFriendlyFire(boolean b)
  { this.allowFriendlyFire = b; }

  // provided by configuration file
  protected static boolean allowTies = false;

  /**
   * Checks if ties are allowed on this server.
   *
   * @return true if ties are allowed, otherwise false
   */
  public static boolean areTiesAllowed()
  { return allowTies; }

  /**
   * Sets whether ties are allowed on this server.
   */
  public static void setAllowTies(boolean b)
  { AutoRefMatch.allowTies = b; }

  // list of items players may not craft
  protected Set<BlockData> prohibitCraft = Sets.newHashSet();

  // range of inexact placement
  protected int inexactRange = 2;

  /**
   * Gets the distance an objective may be placed from its target location.
   *
   * @return range of inexact objective placement
   */
  public int getInexactRange()
  { return inexactRange; }

  // transcript of every event in the match
  protected List<TranscriptEvent> transcript;

  private boolean refereeReady = false;

  /**
   * Checks if the referees are ready for the match to start.
   *
   * @return true if referees are ready or there are no referees, otherwise false
   */
  public boolean isRefereeReady()
  { return getReferees().size() == 0 || refereeReady; }

  /**
   * Sets whether the referees are ready for the match to start.
   */
  public void setRefereeReady(boolean r)
  { refereeReady = r; }

  private ReportGenerator matchReportGenerator = new ReportGenerator();

  public void saveMapImage()
  {
    try
    {
      RenderedImage mapImage = getMapImage();
      ImageIO.write(mapImage, "png", new File(getWorld().getWorldFolder(), "map.png"));
    }
    catch (IOException e) { e.printStackTrace(); }
  }

  public RenderedImage getMapImage() throws IOException
  {
    CuboidRegion cube = getMapCuboid();
    if (cube == null) throw new IOException("No start regions defined.");

    Location min = cube.getMinimumPoint(),
      max = cube.getMaximumPoint();

    return MapImageGenerator.generateFromWorld(getWorld(),
      min.getBlockX(), max.getBlockX(), min.getBlockZ(), max.getBlockZ());
  }

  protected GameMode gamemode;

  // number of seconds for each phase
  public static final int READY_SECONDS = 15;
  public static final int COMPLETED_SECONDS = 180;

  private int customReadyDelay = -1;

  /**
   * Gets number of seconds between start of countdown and match starting.
   *
   * @return number of seconds for match countdown
   */
  public int getReadyDelay()
  {
    if (customReadyDelay >= 0) return customReadyDelay;
    return AutoReferee.getInstance().getConfig().getInt(
      "delay-seconds.ready", AutoRefMatch.READY_SECONDS);
  }

  /**
   * Sets number of seconds between start of countdown and match starting.
   */
  public void setReadyDelay(int delay)
  { this.customReadyDelay = delay; }

  public void notify(Location loc, String message)
  {
    // give spectators a location to warp to (null is acceptable)
    this.setLastNotificationLocation(loc);

    // send a notification message
    if (message.trim().isEmpty()) message = "A notification has been sent. Type /artp to teleport.";
    String m = ChatColor.DARK_GRAY + "[N] " + message;
    for (Player pl : this.getReferees(false)) pl.sendMessage(m);
  }

  private Location lastNotificationLocation = null;

  public Location getLastNotificationLocation()
  { return lastNotificationLocation; }

  /**
   * Sets a notification location for referees and streamers. This location should be the
   * exact location of the event. The teleportation suite will find a suitable vantage point
   * to observe the event.
   *
   * @param loc notification location
   */
  public void setLastNotificationLocation(Location loc)
  { lastNotificationLocation = loc; }

  private Location lastDeathLocation = null;

  public Location getLastDeathLocation()
  { return lastDeathLocation; }

  public void setLastDeathLocation(Location loc)
  {
    lastDeathLocation = loc;
    setLastNotificationLocation(loc);
  }

  private Location lastLogoutLocation = null;

  public Location getLastLogoutLocation()
  { return lastLogoutLocation; }

  public void setLastLogoutLocation(Location loc)
  {
    lastLogoutLocation = loc;
    setLastNotificationLocation(loc);
  }

  private Location lastTeleportLocation = null;

  public Location getLastTeleportLocation()
  { return lastTeleportLocation; }

  public void setLastTeleportLocation(Location loc)
  {
    lastTeleportLocation = loc;
    setLastNotificationLocation(loc);
  }

  private Location lastObjectiveLocation = null;

  public Location getLastObjectiveLocation()
  { return lastObjectiveLocation; }

  public void setLastObjectiveLocation(Location loc)
  {
    lastObjectiveLocation = loc;
    setLastNotificationLocation(loc);
  }

  public class BedUpdateTask extends BukkitRunnable
  {
    private Map<AutoRefPlayer, Boolean> hasBed = Maps.newHashMap();
    private String breakerName, breakAction = "broken";
    private AutoRefPlayer breaker;

    public BedUpdateTask(AutoRefPlayer breaker)
    { this(breaker.getDisplayName()); this.breaker = breaker; }

    public BedUpdateTask(Entity ent)
    {
      AutoReferee plugin = AutoReferee.getInstance();
      switch (ent.getType())
      {
        case CREEPER: breakerName = "Creeper"; break;
        case LIGHTNING: breakerName = "Lightning"; break;
        case WITHER_SKULL: breakerName = "Wither Skull"; break;
        case WITHER: breakerName = "Wither"; break;
        case ENDER_CRYSTAL: breakerName = "Ender Crystal"; break;
        case ENDER_DRAGON: breakerName = "Ender Dragon"; break;

        case FIREBALL:
        case SMALL_FIREBALL:
          breakerName = "Fireball"; break;

        case PRIMED_TNT:
          AutoRefPlayer tntOwner = plugin.getTNTOwner(ent);
          if (tntOwner == null) breakerName = "TNT";
          else breakerName = String.format("%s's TNT", tntOwner.getDisplayName());
          break;
      }

      for (AutoRefPlayer apl : getPlayers())
        hasBed.put(apl, apl.hasBed());
      breakAction = "blown up";
    }

    public BedUpdateTask(String breakerName)
    {
      this.breakerName = breakerName;
      for (AutoRefPlayer apl : getPlayers())
        hasBed.put(apl, apl.hasBed());
      breakAction = "broken";
    }

    public void run()
    {
      Set<AutoRefPlayer> lostBed = Sets.newHashSet();
      String bedBreakNotification;

      for (AutoRefPlayer apl : getPlayers())
        if (hasBed.get(apl) != apl.hasBed()) lostBed.add(apl);

      // if no one's bed changed, quit here
      if (lostBed.isEmpty()) return;

      // don't print or do anything if the bed's owner breaks it himself
      if (breaker != null && lostBed.contains(breaker)) return;

      if (lostBed.size() == 1)
        bedBreakNotification = String.format("%s's bed has been %s by %s.",
          ((AutoRefPlayer) lostBed.toArray()[0]).getDisplayName(), breakAction, breakerName);
      else
      {
        // get the team that owns this bed (null if owned by more than one team)
        AutoRefTeam teamOwner = ((AutoRefPlayer) lostBed.toArray()[0]).getTeam();
        for (AutoRefPlayer apl : lostBed) if (apl.getTeam() != teamOwner) teamOwner = null;

        bedBreakNotification = teamOwner != null
          ? String.format("%s's bed has been %s by %s.", teamOwner.getDisplayName(), breakAction, breakerName)
          : String.format("%s has %s a bed.", breakerName, breakAction);
      }

      for (Player ref : getReferees(false))
        ref.sendMessage(bedBreakNotification);
    }
  }

  private class PlayerCountTask extends BukkitRunnable
  {
    private long lastOccupiedTime = 0;

    public PlayerCountTask()
    { lastOccupiedTime = ManagementFactory.getRuntimeMXBean().getUptime(); }

    public void run()
    {
      long tick = ManagementFactory.getRuntimeMXBean().getUptime();

      // if there are people in this world/match, reset last-occupied
      if (getUserCount() != 0) lastOccupiedTime = tick;

      // if this world has been inactive for long enough, just unload it
      if (tick - lastOccupiedTime >= getCurrentState().inactiveMillis)
        destroy(MatchUnloadEvent.Reason.EMPTY);
    }
  }

  PlayerCountTask countTask = null;

  public AutoRefMatch(World world, boolean tmp, MatchStatus state)
  { this(world, tmp); setCurrentState(state); }

  public AutoRefMatch(World world, boolean tmp)
  {
    setPrimaryWorld(world);
    world.setKeepSpawnInMemory(true);

    // is this world a temporary world?
    this.tmp = tmp;

    // should eliminated players become spectators?
    this.playersBecomeSpectators = AutoReferee.getInstance().getConfig()
      .getBoolean("players-become-spectators", true);

    // setup custom scoreboard
    scoreboard = Bukkit.getScoreboardManager().getNewScoreboard();
     infoboard = Bukkit.getScoreboardManager().getNewScoreboard();

    if (AutoRefMatch.REPLACE_INTERNAL_SCOREBOARD) try
    {
      Method wHandle = world.getClass().getDeclaredMethod("getHandle");
      Object nmsWorld = wHandle.invoke(world);

      Method sHandle = scoreboard.getClass().getDeclaredMethod("getHandle");
      Object nmsScoreboard = sHandle.invoke(scoreboard);

      Field fScoreboard = nmsWorld.getClass().getField("scoreboard");
      fScoreboard.setAccessible(true);
      fScoreboard.set(nmsWorld, nmsScoreboard);
    }
    catch (Exception e)
    {
      AutoReferee.log("A problem occured whilst modifying NMS scoreboard internal values.");
      AutoReferee.log("Are you sure you are using a CraftBukkit variant?");
      AutoReferee.log("Please file a bug report, as this is a somewhat serious error.");
      e.printStackTrace();
    }

    messageReferees("match", getWorld().getName(), "init");
    loadWorldConfiguration();

    messageReferees("match", getWorld().getName(), "map", getMapName());
    setCurrentState(MatchStatus.WAITING);

    // restore competitive settings and some default values
    primaryWorld.setPVP(true);
    primaryWorld.setSpawnFlags(true, true);

    primaryWorld.setTicksPerAnimalSpawns(-1);
    primaryWorld.setTicksPerMonsterSpawns(-1);

    // last, send an update about the match to everyone logged in
    for (Player pl : primaryWorld.getPlayers()) sendMatchInfo(pl);

    // brand new match transcript
    transcript = Lists.newLinkedList();

    // fix vanish
    this.setupSpectators();

    // setup player count task (after assigning the world)
    countTask = new PlayerCountTask();

    // startup the player count timer (for automatic unloading)
    countTask.runTaskTimer(AutoReferee.getInstance(), 5L, 60*20L);
  }

  /**
   * Gets number of users (players and spectators) present in this match.
   *
   * @return number of users
   */
  public int getUserCount()
  { return primaryWorld.getPlayers().size(); }

  public Set<AutoRefPlayer> getPlayers()
  {
    Set<AutoRefPlayer> players = Sets.newHashSet();
    for (AutoRefTeam team : teams)
      players.addAll(team.getPlayers());
    return players;
  }

  public Set<AutoRefPlayer> getCachedPlayers()
  {
    Set<AutoRefPlayer> players = Sets.newHashSet();
    for (AutoRefTeam team : teams)
      players.addAll(team.getCachedPlayers());
    return players;
  }

  /**
   * Gets all match spectators (spectators, referees, and streamers).
   *
   * @return collection of spectators
   */
  public Set<Player> getSpectators()
  {
    Set<Player> specs = Sets.newHashSet();
    for (Player p : primaryWorld.getPlayers())
      if (!isPlayer(p)) specs.add(p);
    return specs;
  }

  /**
   * Gets all non-streamer referees present in this match.
   *
   * @return collection of referees
   */
  public Set<Player> getReferees()
  { return getReferees(true); }

  /**
   * Gets referees present in this match, possibly excluding streamers.
   *
   * @param excludeStreamers whether streamers should be included
   * @return collection of referees
   */
  public Set<Player> getReferees(boolean excludeStreamers)
  {
    Set<Player> refs = Sets.newHashSet();
    for (Player p : primaryWorld.getPlayers())
      if (isReferee(p) && !(excludeStreamers && isStreamer(p))) refs.add(p);
    return refs;
  }

  /**
   * Gets streamers present in this match.
   *
   * @return collection of streamers
   */
  public Set<Player> getStreamers()
  {
    Set<Player> streamers = Sets.newHashSet();
    for (Player p : primaryWorld.getPlayers())
      if (isStreamer(p)) streamers.add(p);
    return streamers;
  }

  /**
   * Checks if the specified player is a referee for this match.
   *
   * @return true if player is a referee and not on a team, otherwise false
   */
  public boolean isReferee(Player player)
  {
    if (isPlayer(player) || getExpectedPlayers().contains(player.getName())) return false;
    return player.hasPermission("autoreferee.referee");
  }

  /**
   * Checks if the specified player is a streamer for this match.
   *
   * @return true if player is a streamer and not on a team, otherwise false
   */
  public boolean isStreamer(Player player)
  {
    if (isPlayer(player) || getExpectedPlayers().contains(player.getName())) return false;
    return isSpectator(player) && getSpectator(player).isStreamer();
  }

  /**
   * Checks if the specified player is a spectator for this match.
   *
   * @return true if player is a spectator, otherwise false
   */
  public boolean isSpectator(Player player)
  {
    return isReferee(player) || !getCurrentState().isBeforeMatch() && !isPlayer(player);
  }

  Map<String, AutoRefSpectator> spectators = Maps.newHashMap();

  public AutoRefSpectator getSpectator(Player player)
  {
    if (!isSpectator(player)) return null;
    String name = player.getName();

    AutoRefSpectator spectator = this.spectators.get(name);
    if (spectator == null) this.spectators.put(name,
      spectator = new AutoRefSpectator(name, this));
    return spectator;
  }

  public enum Role
  {
    // this list provides an ordering of roles. do not permute.
    NONE, PLAYER, SPECTATOR, STREAMER, REFEREE;

    public int getRank()
    { return this.ordinal(); }

    public boolean atLeast(Role other)
    { return getRank() >= other.getRank(); }
  }

  /**
   * Gets the role that this player has in this match.
   *
   * @return a role corresponding to this player
   */
  public Role getRole(OfflinePlayer player)
  {
    if (isPlayer(player)) return Role.PLAYER;
    if (!player.isOnline()) return Role.NONE;

    Player pl = player.getPlayer();
    if (pl.hasPermission("autoreferee.streamer")) return Role.STREAMER;
    if (pl.hasPermission("autoreferee.referee")) return Role.REFEREE;
    if (!getCurrentState().isBeforeMatch()) return Role.SPECTATOR;

    return Role.NONE;
  }

  /**
   * Checks if the given world is compatible with AutoReferee
   * @param world world to check
   * @return true if the world contains a config file, otherwise false
   */
  public static boolean isCompatible(World world)
  { return new File(world.getWorldFolder(), AutoReferee.CFG_FILENAME).exists(); }

  /**
   * Reloads world configuration from config file.
   */
  public void reload()
  { this.loadWorldConfiguration(); }

  protected void loadWorldConfiguration()
  {
    try
    {
      // file stream and configuration object (located in world folder)
      File f = worldConfigFile;
      loadWorldConfiguration(f.exists() ? new FileInputStream(f)
        : AutoReferee.getInstance().getResource("defaults/map.xml"));
    }
    catch (FileNotFoundException e)
    { e.printStackTrace(); }
  }

  protected void clearScoreboardData(Scoreboard sb)
  {
    // unregister all old objectives (created by AutoReferee)
    for (Objective obj : scoreboard.getObjectives())
      if (obj.getName().startsWith("ar#")) obj.unregister();

    // unregister all old teams (created by AutoReferee)
    for (Team team : scoreboard.getTeams())
      if (team.getName().startsWith("ar#")) team.unregister();
  }

  protected void loadScoreboardData()
  {
    clearScoreboardData(scoreboard);
    clearScoreboardData( infoboard);

    // register our custom objective for the sideboard
    long randx = System.currentTimeMillis() % (1L << 16);
    infoboardObjective = infoboard.registerNewObjective(
      String.format("ar#scores_%x", randx), "dummy");

    // kill count objective
    Objective infoKillCount = infoboard.registerNewObjective(
      String.format("ar#kills_%x", randx), "playerKillCount");
    infoKillCount.setDisplayName(ChatColor.BOLD + "Kills");

    // death count objective
    Objective infoDeathCount = infoboard.registerNewObjective(
      String.format("ar#deaths_%x", randx), "deathCount");
    infoDeathCount.setDisplayName(ChatColor.BOLD + "Deaths");

    // objectives list (for cycling through after the match)
    allInfoObjectives = Lists.newArrayList(infoboardObjective, infoKillCount, infoDeathCount);

    try
    {
      File dataFolder = new File(primaryWorld.getWorldFolder(), "data");
      File scoreboardFile = new File(dataFolder, "scoreboard.xml");
      Element sbroot = new SAXBuilder().build(scoreboardFile).getRootElement();

      for (Element teamnode : sbroot.getChild("teams").getChildren("team"))
      {
        Team team = scoreboard.registerNewTeam(teamnode.getAttributeValue("name"));
        team.setPrefix(teamnode.getAttributeValue("prefix"));
        team.setSuffix(teamnode.getAttributeValue("suffix"));
      }

      for (Element objroot : sbroot.getChild("objectives").getChildren("objective"))
      {
        Objective obj = scoreboard.registerNewObjective(
            objroot.getAttributeValue("name"),
            objroot.getAttributeValue("criteria"));

        if (objroot.getAttributeValue("display") != null)
          obj.setDisplaySlot(DisplaySlot.valueOf(objroot.getAttributeValue("display")));
      }

      AutoReferee.log("Loaded custom scoreboard data.");
    }
    catch (FileNotFoundException ignored)
    { }
    catch (IOException e)
    { e.printStackTrace(); }
    catch (JDOMException e)
    { e.printStackTrace(); }
  }

  public void saveScoreboardData()
  { saveScoreboardData(scoreboard); }

  public void saveScoreboardData(Scoreboard sb)
  {
    Element teams = new Element("teams");
    for (Team team : sb.getTeams())
    {
      Element teamnode = new Element("team");
      teamnode.setAttribute("name", team.getName());
      teamnode.setAttribute("prefix", team.getPrefix());
      teamnode.setAttribute("suffix", team.getSuffix());
      teams.addContent(teamnode);
    }

    teams.sortChildren(new Comparator<Element>()
    {
      @Override
      public int compare(Element a, Element b)
      {
        String aname = a.getAttributeValue("name");
        String bname = b.getAttributeValue("name");
        return aname.compareToIgnoreCase(bname);
      }
    });

    Element objectives = new Element("objectives");
    for (Objective objective : sb.getObjectives())
    {
      Element objnode = new Element("objective");
      objnode.setAttribute("name", objective.getName());
      objnode.setAttribute("criteria", objective.getCriteria());
      if (objective.getDisplaySlot() != null)
        objnode.setAttribute("display", objective.getDisplaySlot().name());
      objectives.addContent(objnode);
    }

    objectives.sortChildren(new Comparator<Element>()
    {
      @Override
      public int compare(Element a, Element b)
      {
        String aname = a.getAttributeValue("name");
        String bname = b.getAttributeValue("name");
        return aname.compareToIgnoreCase(bname);
      }
    });

    Element sbroot = new Element("scoreboard");
    sbroot.addContent(teams);
    sbroot.addContent(objectives);

    try
    {
      XMLOutputter xmlout = new XMLOutputter(Format.getPrettyFormat());
      File dataFolder = new File(primaryWorld.getWorldFolder(), "data");
      xmlout.output(sbroot, new FileOutputStream(new File(dataFolder, "scoreboard.xml")));
    }
    catch (java.io.IOException e)
    { AutoReferee.log("Could not save scoreboard data: " + primaryWorld.getName()); }
  }

  protected void loadWorldConfiguration(InputStream cfg)
  {
    try
    {
      // until told otherwise, assume that what we have should not be
      // saved (to prevent a bad config from being destroyed)
      saveConfig = false;

      // build configuration file from
      worldConfig = new SAXBuilder().build(cfg).getRootElement();

      // turn on saving functionality if we loaded a configuration properly
      assert "map".equals(worldConfig.getName());
      saveConfig = true;
    }
    catch (JDOMParseException e)
    {
      AutoReferee.log(String.format(">> With configuration file: %s [%s]",
        worldConfigFile.getPath(), getWorld().getName()), Level.SEVERE);
      AutoReferee.log(e.getLocalizedMessage(), Level.SEVERE);

      // maybe try to salvage the partially parsed document?
      worldConfig = e.getPartialDocument().getRootElement();
      assert "map".equals(worldConfig.getName());
    }
    catch (Exception e) { e.printStackTrace(); return; }

    loadScoreboardData();

    this.gamemode = GameMode.SURVIVAL;

    // get the extra settings cached
    Element meta = worldConfig.getChild("meta");
    if (meta != null)
    {
      mapName = meta.getChildText("name");
      infoboardObjective.setDisplayName(ChatColor.BOLD + mapName);
      versionString = meta.getChildText("version");

      mapAuthors = Lists.newLinkedList();
      for (Element e : meta.getChild("creators").getChildren("creator"))
        mapAuthors.add(e.getText());
    }

    // set the time limit based on the server config
    long limit_min = AutoReferee.getInstance().getConfig().getLong("time-limit", 0L);
    this.setTimeLimit(60L * limit_min);

    Element kitsElt = worldConfig.getChild("kits");
    kits = Maps.newHashMap();

    // parse kits before parsing teams
    if (kitsElt != null) for (Element kitElt : kitsElt.getChildren("kit"))
    {
      PlayerKit kit = new PlayerKit(kitElt);
      kits.put(kit.getName(), kit);
    }

    teams = Sets.newHashSet();
    for (Element e : worldConfig.getChild("teams").getChildren("team"))
      teams.add(AutoRefTeam.fromElement(e, this));

    Element eProtect = worldConfig.getChild("protect");
    protectedEntities = Sets.newHashSet();

    if (eProtect != null) for (Element c : eProtect.getChildren())
      try { protectedEntities.add(UUID.fromString(c.getTextTrim())); }
      catch (IllegalArgumentException ignored) {  }

    // get the start region (safe for both teams, no pvp allowed)
    assert worldConfig.getChild("startregion") != null;
    for (Element e : worldConfig.getChild("startregion").getChildren())
      addStartRegion(AutoRefRegion.fromElement(this, e));

    String attrSpawn = worldConfig.getChild("startregion").getAttributeValue("spawn");
    if (attrSpawn != null) setWorldSpawn(LocationUtil.fromCoords(getWorld(), attrSpawn));

    String attrSpecSpawn = worldConfig.getChild("startregion").getAttributeValue("spec");
    if (attrSpecSpawn != null) setSpectatorSpawn(LocationUtil.fromCoords(getWorld(), attrSpecSpawn));

    Element gameplay = worldConfig.getChild("gameplay");
    if (gameplay != null) this.parseExtraGameRules(gameplay);

    Element regElt = worldConfig.getChild("regions");
    regions = Sets.newHashSet();

    for (Element reg : regElt.getChildren())
      if (!this.addRegion(AutoRefRegion.fromElement(this, reg)))
        AutoReferee.log("Region did not load correctly: " + reg.getName(), java.util.logging.Level.SEVERE);

    Element goals = worldConfig.getChild("goals");
    if (goals != null) for (Element teamgoals : goals.getChildren("teamgoals"))
    {
      AutoRefTeam team = this.getTeam(teamgoals.getAttributeValue("team"));
      if (team != null) for (Element gelt : teamgoals.getChildren()) team.addGoal(gelt);
    }

    Element mechanisms = worldConfig.getChild("mechanisms");
    startMechanisms = Sets.newHashSet();

    if (mechanisms != null) for (Element mech : mechanisms.getChildren())
    {
      boolean state = Boolean.parseBoolean(mech.getText());
      Location mechloc = LocationUtil.fromCoords(getWorld(), mech.getAttributeValue("pos"));
      this.toggleStartMech(getWorld().getBlockAt(mechloc), state);
    }

    // setup scoreboard for the teams (on next server tick)
    setupScoreboardObjectives();
  }

  private static Difficulty getDifficulty(String d)
  {
    Difficulty diff = Difficulty.valueOf(d.toUpperCase());
    try { diff = Difficulty.getByValue(Integer.parseInt(d)); }
    catch (NumberFormatException ignored) {  }

    return diff;
  }

  private void parseExtraGameRules(Element gameplay)
  {
    // get the time the match is set to start
    if (gameplay.getChild("clockstart") != null)
    {
      startClock = AutoRefMatch.parseTimeString(gameplay.getChildText("clockstart"));
      lockTime = gameplay.getChild("clockstart").getAttributeValue("lock") != null;

      // @since 1.6.1, "doDaylightCycle=false" locks time
      if (primaryWorld.isGameRule("doDaylightCycle"))
      {
        // set the gamerule to lock the time (or don't lock the time, see if I care!)
        primaryWorld.setGameRuleValue("doDaylightCycle", "" + !lockTime);

        // disable here to prevent the version based on setting the time
        lockTime = false;
      }
    }

    // set any specified gamerules (future-proof?)
    if (gameplay.getChild("gamerules") != null)
    {
      for (Element rulenode : gameplay.getChild("gamerule").getChildren())
        if (primaryWorld.isGameRule(rulenode.getName()))
          primaryWorld.setGameRuleValue(rulenode.getName(), rulenode.getTextNormalize());
    }

    // allow or disallow friendly fire
    if (gameplay.getChild("friendlyfire") != null)
      setFriendlyFire(Boolean.parseBoolean(gameplay.getChildText("friendlyfire")));

    // attempt to set world difficulty as best as possible
    Difficulty diff = Difficulty.HARD;
    if (gameplay.getChild("difficulty") != null)
      diff = getDifficulty(gameplay.getChildText("difficulty"));
    primaryWorld.setDifficulty(diff);

    if (gameplay.getChild("maxtime") != null)
      this.setTimeLimit(TimeGoal.parseTime(gameplay.getChildText("maxtime")));

    // respawn mode
    if (gameplay.getChild("respawn") != null)
    {
      String rtext = gameplay.getChildTextTrim("respawn");
      RespawnMode rmode = null;

      if (rtext != null && !rtext.isEmpty())
        rmode = RespawnMode.valueOf(rtext.toUpperCase());
      setRespawnMode(rmode == null ? RespawnMode.ALLOW : rmode);
    }

    if (gameplay.getChild("nocraft") != null)
    {
      for (Element item : gameplay.getChild("nocraft").getChildren("item"))
        this.addIllegalCraft(BlockData.unserialize(item.getAttributeValue("id")));
    }

    if (gameplay.getChild("gamemode") != null)
    {
      String gm = gameplay.getChildTextNormalize("gamemode");
      this.gamemode = GameMode.valueOf(gm.toUpperCase());

      try { this.gamemode = GameMode.getByValue(Integer.parseInt(gm)); }
      catch (NumberFormatException ignored) {  }

      if (this.gamemode == null)
        this.gamemode = GameMode.SURVIVAL;
    }
  }

  private void setupScoreboardObjectives()
  {
    // defer to prevent exception on server start,
    // before any worlds are fully loaded
    new BukkitRunnable()
    {
      @Override
      public void run()
      {
        // setup the objectives for each team
        for (AutoRefTeam team : getTeams())
        {
          team.scoreboardObjectives = AutoRefObjective.fromTeam(infoboardObjective, team);
          if (!team.scoreboardObjectives.isEmpty())
            infoboardObjective.setDisplaySlot(DisplaySlot.SIDEBAR);
        }
      }
    // run this task on the next server tick
    }.runTask(AutoReferee.getInstance());
  }

  /**
   * Saves copy of autoreferee.xml back to the world folder.
   */
  public void saveWorldConfiguration()
  {
    // if for some reason we have disabled the saveConfig flag,
    // just do nothing. more than likely, trying to save will do more
    // harm than good, so best to just skip this entirely
    if (!saveConfig) return;

    // if there is no configuration object or file, nothin' doin'...
    if (worldConfig == null)
    {
      try
      {
        InputStream mapXML = AutoReferee.getInstance().getResource("defaults/map.xml");
        worldConfig = new SAXBuilder().build(mapXML).getRootElement();
      }
      catch (Exception e) { e.printStackTrace(); return; }
    }

    else
    {
      // get the teams object
      Element eTeams = worldConfig.getChild("teams");
      if (eTeams == null) worldConfig.addContent(eTeams = new Element("team"));

      // reset the teams to whatever has been saved
      eTeams.removeContent();
      for (AutoRefTeam team : teams)
        eTeams.addContent(team.toElement());

      // get the regions object
      Element eRegions = worldConfig.getChild("regions");
      if (eRegions == null) worldConfig.addContent(eRegions = new Element("regions"));

      // reset the regions to whatever has been saved
      eRegions.removeContent();
      for (AutoRefRegion reg : this.getRegions())
        eRegions.addContent(reg.toElement());

      // get startregion object
      Element eStartRegions = worldConfig.getChild("startregion");
      if (getWorldSpawn() != null) eStartRegions.setAttribute("spawn",
        LocationUtil.toBlockCoordsWithYaw(getWorldSpawn()));

      if (specSpawn != null) eStartRegions.setAttribute("spec",
        LocationUtil.toBlockCoordsWithYaw(specSpawn.getCenter()));

      eStartRegions.removeContent();
      for (AutoRefRegion reg : this.getStartRegions())
        eStartRegions.addContent(reg.toElement());

      // get the protections object
      Element eProtect = worldConfig.getChild("protect");
      if (eProtect == null) worldConfig.addContent(eProtect = new Element("protect"));

      // reset the protections to whatever has been saved
      eProtect.removeContent();
      for (UUID uid : protectedEntities)
        eProtect.addContent(new Element("entity").setText(uid.toString()));

      // get the goals object
      Element eGoals = worldConfig.getChild("goals");
      if (eGoals == null) worldConfig.addContent(eGoals = new Element("goals"));

      // reset the goals to whatever has been saved
      eGoals.removeContent();
      for (AutoRefTeam team : this.getTeams())
      {
        Element tgoals = new Element("teamgoals")
          .setAttribute("team", team.getDefaultName());
        eGoals.addContent(tgoals);
        for (AutoRefGoal goal : team.getTeamGoals())
          tgoals.addContent(goal.toElement());
      }

      // get the mechanisms object
      Element eMechanisms = worldConfig.getChild("mechanisms");
      if (eMechanisms == null) worldConfig.addContent(eMechanisms = new Element("mechanisms"));

      // reset the mechanisms to whatever has been saved
      eMechanisms.removeContent();
      for (StartMechanism mech : this.startMechanisms)
        eMechanisms.addContent(mech.toElement());

      Element eGameplay = worldConfig.getChild("gameplay");
      if (eGameplay == null) worldConfig.addContent(eGameplay = new Element("gameplay"));

      if (this.prohibitCraft.size() > 0)
      {
        Element eNoCraft = eGameplay.getChild("nocraft");
        if (eNoCraft == null) eGameplay.addContent(eNoCraft = new Element("nocraft"));

        eNoCraft.removeContent();
        for (BlockData bd : prohibitCraft)
        {
          Element nocraft = new Element("item").setText(bd.getName());
          eNoCraft.addContent(nocraft.setAttribute("id", bd.serialize()));
        }
      }
    }

    // save the configuration file back to the original filename
    try
    {
      XMLOutputter xmlout = new XMLOutputter(Format.getPrettyFormat());
      xmlout.output(worldConfig, new FileOutputStream(worldConfigFile));
    }

    // log errors, report which world did not save
    catch (java.io.IOException e)
    { AutoReferee.log("Could not save world config: " + primaryWorld.getName()); }
  }

  /**
   * Sends a referee plugin channel message to all referees, properly delimited.
   */
  public void messageReferees(String ...parts)
  {
    for (Player ref : getReferees(false)) messageReferee(ref, parts);

    // if there is a URI set for a node server receiving match messages, send
    if (AutoReferee.getInstance().getConfig().isSet("node-api-url")) try
    {
      String url = AutoReferee.getInstance().getConfig().getString("node-api-url");
      QueryUtil.syncPutQuery(url, QueryUtil.prepareParams(ImmutableMap.of(
          "msg", StringUtils.join(parts, SpectatorListener.DELIMITER),
          "world", getWorld().getName()
      )));
    }
    catch (IOException ignored) {}
  }

  /**
   * Sends a referee plugin channel message to a specific referee, properly delimited.
   *
   * @param ref referee to recieve the plugin channel message
   */
  public static void messageReferee(Player ref, String ...parts)
  {
    try
    {
      String msg = StringUtils.join(parts, SpectatorListener.DELIMITER);
      ref.sendPluginMessage(AutoReferee.getInstance(), AutoReferee.REFEREE_PLUGIN_CHANNEL,
        msg.getBytes(AutoReferee.PLUGIN_CHANNEL_ENC));
    }
    catch (UnsupportedEncodingException e)
    { AutoReferee.log("Unsupported encoding: " + AutoReferee.PLUGIN_CHANNEL_ENC); }
  }

  /**
   * Sends all information to a single referee necessary to sync a match's current status.
   *
   * @param ref referee to receive the plugin channel messages
   */
  public void updateReferee(Player ref)
  {
    messageReferee(ref, "match", getWorld().getName(), "init");
    messageReferee(ref, "match", getWorld().getName(), "map", getMapName());

    if (getCurrentState().inProgress())
      messageReferee(ref, "match", getWorld().getName(), "time", getTimestamp(","));

    for (AutoRefTeam team : getTeams())
    {
      messageReferee(ref, "team", team.getName(), "init");
      messageReferee(ref, "team", team.getName(), "color", team.getColor().toString());

      for (AutoRefGoal goal : team.getTeamGoals())
        goal.updateReferee(ref);

      for (AutoRefPlayer apl : team.getPlayers())
      {
        messageReferee(ref, "team", team.getName(), "player", "+" + apl.getName());
        updateRefereePlayerInfo(ref, apl);
      }
    }
  }

  private void updateRefereePlayerInfo(Player ref, AutoRefPlayer apl)
  {
    messageReferee(ref, "player", apl.getName(), "kills", Integer.toString(apl.getKills()));
    messageReferee(ref, "player", apl.getName(), "deaths", Integer.toString(apl.getDeathCount()));
    messageReferee(ref, "player", apl.getName(), "streak", Integer.toString(apl.getStreak()));
    apl.sendAccuracyUpdate(ref);

    Player pl = apl.getPlayer();
    if (pl != null)
    {
      messageReferee(ref, "player", apl.getName(), "hp", Integer.toString(pl.getHealth()));
      messageReferee(ref, "player", apl.getName(), "armor", Integer.toString(ArmorPoints.fromPlayer(pl)));
    }

    for (AutoRefPlayer en : getPlayers()) if (apl.isDominating(en))
      messageReferee(ref, "player", apl.getName(), "dominate", en.getName());

    messageReferee(ref, "player", apl.getName(), apl.isOnline() ? "login" : "logout");
    messageReferee(ref, "player", apl.getName(), "cape", apl.getCape());
  }

  private class ItemElevatorDetectionTask extends BukkitRunnable
  {
    private static final long INTERVAL = 5L;
    private static final double DISTANCE_THRESHOLD = 1.8;
    private static final double YDELTA_THRESHOLD = 0.8;

    private Map<UUID, Location> itemLocations = Maps.newHashMap();
    private Map<UUID, Location> lastStoppedLocation = Maps.newHashMap();

    @Override public void run()
    {
      for (Entity e : getWorld().getEntitiesByClasses(Item.class))
      {
        Item item = (Item) e;
        UUID uuid = item.getUniqueId();

        Location prev = itemLocations.get(uuid);
        Location curr = e.getLocation();
        Location stop = lastStoppedLocation.get(uuid);

        if (prev == null) continue;
        boolean pass = TeleportationUtil.isBlockPassable(curr.getBlock());

        // if the item is moving upwards and is currently in a passable
        double ydelta = curr.getY() - prev.getY();
        if (ydelta > YDELTA_THRESHOLD && !pass && !elevatedItem.containsKey(uuid))
          elevatedItem.put(uuid, false);

        double dy = stop == null ? 0.0 : curr.getY() - stop.getY();
        if (elevatedItem.containsKey(uuid) && dy >= DISTANCE_THRESHOLD)
          elevatedItem.put(uuid, true);

        if (ydelta < 0.001)
        {
          // record the last location it was stopped at
          lastStoppedLocation.put(uuid, curr);

          boolean atrest = !TeleportationUtil.isBlockPassable(curr.getBlock().getRelative(0, -1, 0));
          if (elevatedItem.containsKey(uuid) && elevatedItem.get(uuid) && atrest)
          {
            // if the item didn't elevate high enough, don't worry about it
            if (dy < DISTANCE_THRESHOLD) { elevatedItem.remove(uuid); continue; }
            setLastNotificationLocation(curr);

            String coords = LocationUtil.toBlockCoords(curr);
            String msg = ChatColor.DARK_GRAY + String.format(
              "Possible Item Elevator @ (%s) [y%+d] %s", coords, Math.round(dy),
              new BlockData(item.getItemStack()).getDisplayName());

            for (Player ref : getReferees()) ref.sendMessage(msg);
            AutoReferee.log(msg);
          }
        }
      }

      itemLocations.clear();
      for (Entity e : getWorld().getEntitiesByClasses(Item.class))
        itemLocations.put(e.getUniqueId(), e.getLocation());
    }
  }

  public Map<UUID, Boolean> elevatedItem = Maps.newHashMap();
  protected ItemElevatorDetectionTask itemElevatorDetectionTask = null;

  /**
   * Sends a message to all players in this match, including referees and streamers.
   *
   * @param msgs messages to be sent
   */
  public void broadcast(String ...msgs)
  {
    for (String msg : msgs)
    {
      if (AutoReferee.getInstance().isConsoleLoggingEnabled())
        AutoReferee.log(ChatColor.stripColor(msg));
      for (Player p : primaryWorld.getPlayers()) p.sendMessage(msg);
    }
  }

  private SyncBroadcastTask broadcastTask = new SyncBroadcastTask();

  /**
   * Force a broadcast to be sent synchronously. Safe to use from an asynchronous task.
   *
   * @param msgs messages to be sent
   */
  public void broadcastSync(String ...msgs)
  {
    for (String msg : msgs)
      broadcastTask.addMessage(msg);

    try { broadcastTask.runTask(AutoReferee.getInstance()); }
    catch (IllegalStateException ignored) {  }
  }

  private class SyncBroadcastTask extends BukkitRunnable
  {
    private List<String> msgQueue = Lists.newLinkedList();

    public SyncBroadcastTask addMessage(String message)
    { msgQueue.add(message); return this; }

    @Override public void run()
    {
      AutoRefMatch.this.broadcastTask = new SyncBroadcastTask();
      AutoRefMatch.this.broadcast(msgQueue.toArray(new String[msgQueue.size()]));
      msgQueue.clear();
    }
  }

  /**
   * Removes any non-alphanumeric characters from a map name. Prepares a map name
   * to be used as a file name or a target in a chat command.
   *
   * @param name original map name
   * @return normalized version of map name
   */
  public static String normalizeMapName(String name)
  { return name == null ? null : name.replaceAll("[^0-9a-zA-Z]+", ""); }

  /**
   * Assigns a world a match object. Best suited for retro-fitting worlds that
   * have already been loaded.
   *
   * @param world loaded AutoReferee-compatible world
   * @param tmp whether this world should be unloaded when the match completes
   */
  public static void setupWorld(World world, boolean tmp)
  {
    // if this map isn't compatible with AutoReferee, quit...
    if (AutoReferee.getInstance().getMatch(world) != null || !isCompatible(world)) return;
    AutoReferee.getInstance().addMatch(new AutoRefMatch(world, tmp, MatchStatus.WAITING));
  }

  private static final File PACKAGING_DIRECTORY = FileUtils.getTempDirectory();

  private static class FilenameSetFilter implements FilenameFilter
  {
    private final Set<String> names;

    public FilenameSetFilter(final Set<String> names)
    { this.names = names; }

    @Override public boolean accept(File dir, String filename)
    { return names.contains(filename); }
  }

  private static final IOFileFilter DATA_FOLDER_FILTER =
    FileFilterUtils.asFileFilter(new FilenameSetFilter(Sets.newHashSet
    (   "scoreboard.dat"
    "scoreboard.xml"
    )));

  /**
   * Archives this map and stores a clean copy in the map library. Clears unnecessary
   * files and attempts to generate a minimal copy of the map, ready for distribution.
   *
   * @return root folder of the archived map
   * @throws IOException if archive cannot be created
   */
  private File archiveMapData() throws IOException
  {
    this.clearEntities();
    primaryWorld.setTime(this.getStartClock());

    // save the world and configuration first, then archive
    primaryWorld.save();
    this.saveWorldConfiguration();

    // make sure the folder exists first
    File archiveFolder = new File(PACKAGING_DIRECTORY, this.getVersionString());
    if (!archiveFolder.exists()) FileUtils.forceMkdir(archiveFolder);
    FileUtils.cleanDirectory(archiveFolder);

    // (1) copy the configuration file:
    FileUtils.copyFileToDirectory(
      new File(getWorld().getWorldFolder(), AutoReferee.CFG_FILENAME), archiveFolder);

    // (2) copy the level.dat:
    FileUtils.copyFileToDirectory(
      new File(getWorld().getWorldFolder(), "level.dat"), archiveFolder);

    // (3) copy the region folder (only the .mca files):
    FileUtils.copyDirectory(new File(getWorld().getWorldFolder(), "region"),
      new File(archiveFolder, "region"), FileFilterUtils.suffixFileFilter(".mca"));

    // (4) make an empty data folder:
    FileUtils.copyDirectory(new File(getWorld().getWorldFolder(), "data"),
      new File(archiveFolder, "data"), DATA_FOLDER_FILTER);

    return archiveFolder;
  }

  private static void addToZip(ZipOutputStream zip, File f, File base) throws IOException
  {
    zip.putNextEntry(new ZipEntry(base.toURI().relativize(f.toURI()).getPath()));
    if (f.isDirectory()) for (File c : f.listFiles()) addToZip(zip, c, base);
    else IOUtils.copy(new FileInputStream(f), zip);
  }

  /**
   * Packages and compresses (zip) map folder for easy distribution.
   *
   * @return generated zip file
   * @throws IOException if map cannot be archived
   */
  public File distributeMap() throws IOException
  {
    File archiveFolder = this.archiveMapData();
    File outZipfile = new File(AutoRefMap.getMapLibrary(), this.getVersionString() + ".zip");

    ZipOutputStream zip = new ZipOutputStream(new
      BufferedOutputStream(new FileOutputStream(outZipfile)));
    zip.setMethod(ZipOutputStream.DEFLATED);
    addToZip(zip, archiveFolder, PACKAGING_DIRECTORY);

    zip.close();
    FileUtils.deleteQuietly(archiveFolder);
    return outZipfile;
  }

  private class WorldFolderDeleter extends BukkitRunnable
  {
    private File worldFolder;
    private int deleteAttempts = 5;

    WorldFolderDeleter(World w)
    { this.worldFolder = w.getWorldFolder(); }

    @Override
    public void run()
    {
      World world = AutoReferee.getInstance().getServer().getWorld(worldFolder.getName());
      if (world == null && worldFolder.exists()) try
      {
        // if we fail, we loop back around again on the next try...
        FileUtils.deleteDirectory(worldFolder);
        AutoReferee.log(worldFolder.getName() + " deleted!");
      }
      catch (IOException e)
      { if (deleteAttempts-- > 0) AutoReferee.log("File lock held on " + worldFolder.getName()); }

      // stop the repeating task if the file is gone
      if (!worldFolder.exists()) this.cancel();
    }
  }

  protected class PlayerEjectTask extends BukkitRunnable
  {
    private Player player;
    private Location target;

    protected PlayerEjectTask(Player player, Location target)
    {
      this.player = player;
      this.target = target;
    }

    @Override
    public void run()
    { player.teleport(target); }
  }

  public void ejectPlayer(Player player)
  {
    PlayerMatchLeaveEvent event = new PlayerMatchLeaveEvent(player, this);
    AutoReferee.callEvent(event);
    if (event.isCancelled()) return;

    // resets the player to default state
    PlayerUtil.reset(player);

    // if there is a lobby to teleport them, do so
    World target = AutoReferee.getInstance().getLobbyWorld();
    if (target == null) for (World w : Bukkit.getWorlds())
      if (!AutoRefMatch.isCompatible(w)) { target = w; break; }

    if (target != null)
    {
      PlayerUtil.setGameMode(player, GameMode.SURVIVAL);
      new PlayerEjectTask(player, target.getSpawnLocation()).runTask(AutoReferee.getInstance());
    }

    // otherwise, kick them from the server
    else player.kickPlayer(AutoReferee.COMPLETED_KICK_MESSAGE);
  }

  /**
   * Unloads and cleans up this match. Players will be teleported out or kicked,
   * the map will be unloaded, and the map folder may be deleted.
   */
  public void destroy(MatchUnloadEvent.Reason reason)
  {
    // fire match unload event
    MatchUnloadEvent event = new MatchUnloadEvent(this, reason);
    AutoReferee.callEvent(event);
    if (event.isCancelled()) return;

    // for cleanup purposes, BEFORE we eject all of the players
    this.messageReferees("world", getWorld().getName(), "destroy");

    // first, handle all the players
    for (Player p : primaryWorld.getPlayers()) this.ejectPlayer(p);

    // if everyone has been moved out of this world, clean it up
    if (primaryWorld.getPlayers().size() == 0)
    {
      // if this is OUR world (we can delete it if we want)
      AutoReferee plugin = AutoReferee.getInstance();
      if (this.isTemporaryWorld())
      {
        plugin.clearMatch(this);
        this.countTask.cancel();

        plugin.getServer().unloadWorld(primaryWorld, true);
        if (!plugin.getConfig().getBoolean("save-worlds", false))
          new WorldFolderDeleter(primaryWorld).runTaskTimer(plugin, 0L, 10 * 20L);
      }
    }
  }

  /**
   * Checks if a item is prohibited from crafting.
   *
   * @param blockdata block data object for the item being queried
   * @return true if item may be crafted, otherwise false
   */
  public boolean canCraft(BlockData blockdata)
  {
    for (BlockData nc : prohibitCraft)
      if (nc.equals(blockdata)) return false;
    return true;
  }

  /**
   * Prohibits an item from being crafted during a match.
   *
   * @param blockdata block data object for the prohibited item
   */
  public void addIllegalCraft(BlockData blockdata)
  {
    this.prohibitCraft.add(blockdata);
    this.broadcast("Crafting " + blockdata.getDisplayName() + " is now prohibited");
  }

  /**
   * Gets an arbitrary team, attempting to maintain balanced teams if possible.
   *
   * @return an arbitrary team
   */
  public AutoRefTeam getArbitraryTeam()
  {
    // minimum size of any one team, and an array to hold valid teams
    int minsize = Integer.MAX_VALUE;
    List<AutoRefTeam> vteams = Lists.newArrayList();

    // determine the size of the smallest team
    for (AutoRefTeam team : getTeams())
      if (team.getPlayers().size() < minsize)
        minsize = team.getPlayers().size();

    // make a list of all teams with this size
    for (AutoRefTeam team : getTeams())
      if (team.getPlayers().size() == minsize) vteams.add(team);

    // return a random element from this list
    return vteams.get(new Random().nextInt(vteams.size()));
  }

  private Set<AutoRefRegion> regions;

  public Set<AutoRefRegion> getRegions()
  { return regions; }

  @SuppressWarnings("unchecked")
  public <T extends AutoRefRegion> Set<T> getRegions(Class<T> clazz)
  {
    Set<T> typedRegions = Sets.newHashSet();
    for (AutoRefRegion reg : regions)
      if (clazz.isInstance(reg))
        typedRegions.add((T) reg);
    return typedRegions;
  }

  public Set<AutoRefRegion> getRegions(AutoRefTeam team)
  {
    Set<AutoRefRegion> teamRegions = Sets.newHashSet();
    for (AutoRefRegion reg : regions)
      if (reg.isOwner(team)) teamRegions.add(reg);
    return teamRegions;
  }

  public boolean addRegion(AutoRefRegion reg)
  { return reg != null && !regions.contains(reg) && regions.add(reg); }

  /**
   * A redstone mechanism necessary to start a match.
   *
   * @author authorblues
   */
  public static class StartMechanism
  {
    private Block block = null;
    private BlockState state = null;
    private boolean flip = true;

    public StartMechanism(Block block, boolean flip)
    {
      this.flip = flip;
      this.block = block;
      state = block.getState();
    }

    public Element toElement()
    {
      return new Element(state.getType().name().toLowerCase())
        .setAttribute("pos", LocationUtil.toBlockCoords(block.getLocation()))
        .setText(Boolean.toString(flip));
    }

    @Override public int hashCode()
    { return block.hashCode() ^ state.hashCode(); }

    @Override public boolean equals(Object o)
    { return (o instanceof StartMechanism) && hashCode() == o.hashCode(); }

    public String serialize()
    { return LocationUtil.toBlockCoords(block.getLocation()) + ":" + Boolean.toString(flip); }

    @Override public String toString()
    { return state.getType().name() + "(" + this.serialize() + ")"; }

    public Block getBlock()
    { return block; }

    public BlockState getBlockState()
    { return state; }

    public boolean getFlippedPosition()
    { return flip; }

    public boolean active()
    {
      MaterialData bdata = state.getData();

      if (bdata instanceof Redstone)
        return flip == ((Redstone) bdata).isPowered();
      if (bdata instanceof PressureSensor)
        return flip == ((PressureSensor) bdata).isPressed();
      return false;
    }

    public boolean canFlip(AutoRefMatch match)
    {
      MatchStatus mstate = match.getCurrentState();
      return !mstate.isBeforeMatch() && !active();
    }
  }

  static final Set<Material> EXPECTED_MECHANISMS = Sets.newHashSet
  Material.LEVER
  ,  Material.STONE_BUTTON
  ,  Material.WOOD_BUTTON
  ,  Material.STONE_PLATE
  ,  Material.WOOD_PLATE
  );

  /**
   * Adds a new start mechanism for this map. These mechanisms are activated automatically
   * at the start of a match when using SportBukkit, and players may interact with them
   * normally in the start region when using vanilla CraftBukkit.
   *
   * @param block block containing the start mechanism
   * @param state intended state of the redstone mechanism
   * @return generated start mechanism object
   * @see <a href="http://www.github.com/OvercastNetwork/SportBukkit">SportBukkit</a>
   */
  public StartMechanism toggleStartMech(Block block, boolean state)
  {
    StartMechanism sm = new StartMechanism(block, state);

    boolean adding = startMechanisms.add(sm);
    if (!adding) { startMechanisms.remove(sm); return null; }

    if (!EXPECTED_MECHANISMS.contains(block.getType()))
      AutoReferee.log("Unexpected start mechanism: " + block.getType().name(), Level.WARNING);
    return sm;
  }

  /**
   * Adds a new start mechanism for this map. These mechanisms are activated automatically
   * at the start of a match when using SportBukkit, and players may interact with them
   * normally in the start region when using vanilla CraftBukkit.
   *
   * @param block block containing the start mechanism
   * @return generated start mechanism object
   * @see <a href="http://www.github.com/OvercastNetwork/SportBukkit">SportBukkit</a>
   */
  public StartMechanism toggleStartMech(Block block)
  {
    boolean state = block.getType() != Material.LEVER ||
      ((Redstone) block.getState().getData()).isPowered();
    return this.toggleStartMech(block, state);
  }

  /**
   * Gets the start mechanism associated with this location.
   *
   * @return start mechanism located at that position, otherwise null
   */
  public StartMechanism getStartMechanism(Block block)
  {
    if (block == null) return null;
    for (StartMechanism sm : startMechanisms)
      if (block.equals(sm.getBlock())) return sm;
    return null;
  }

  /**
   * Checks if a specified block location is a start mechanism for this match.
   *
   * @return true if a start mechanism is located at that position, otherwise false
   */
  public boolean isStartMechanism(Block block)
  { return getStartMechanism(block) != null; }

  /**
   * Parameters necessary to configure a match.
   * <p>
   * This class is serialized in through JSON.
   * <p>
   * TODO allow modification thru commands during map publication prep?
   *
   * @author authorblues
   */
  public static class MatchParams implements Serializable
  {
    public static class TeamInfo
    {
      private String name;

      public String getName()
      { return name; }

      private List<String> players;

      public List<String> getPlayers()
      { return Collections.unmodifiableList(players); }
    }

    // info about all the teams
    private List<TeamInfo> teams;

    public List<TeamInfo> getTeams()
    { return Collections.unmodifiableList(teams); }

    // match tag for reporting
    private String tag;

    public String getTag()
    { return tag; }

    // map name
    private String map;

    public String getMap()
    { return map; }
  }

  /**
   * Starts the match.
   */
  protected void _startMatch()
  {
    // set up the world time one last time
    primaryWorld.setTime(startClock);
    this.setStartTime(ManagementFactory.getRuntimeMXBean().getUptime());

    addEvent(new TranscriptEvent(this, TranscriptEvent.EventType.MATCH_START, "Match began.", null));

    // send referees the start event
    messageReferees("match", getWorld().getName(), "start");

    // remove all mobs, animals, and items (again)
    this.clearEntities();

    // loop through all the redstone mechanisms required to start
    for (StartMechanism sm : startMechanisms)
    {
      MaterialData mdata = sm.getBlockState().getData();
      switch (sm.getBlockState().getType())
      {
        case LEVER:
          // flip the lever to the correct state
          ((Lever) mdata).setPowered(sm.getFlippedPosition());
          break;

        case STONE_BUTTON:
          // press (or depress) the button
          ((Button) mdata).setPowered(sm.getFlippedPosition());
          break;

        case WOOD_PLATE:
        case STONE_PLATE:
          // press (or depress) the pressure plate
          ((PressurePlate) mdata).setData((byte)(sm.getFlippedPosition() ? 0x1 : 0x0));
          break;

        default:
          break;
      }

      // save the block state and fire an update
      sm.getBlockState().setData(mdata);
      sm.getBlockState().update(true);

      // FIXME BUKKIT-1858
      if (!SportBukkitUtil.hasSportBukkitApi()) {
        // Determine attached block
        BlockFace face = BlockFace.SELF;
        if (mdata instanceof Attachable) {
          face = ((Attachable) mdata).getAttachedFace();
        } else if (mdata instanceof PressurePlate) {
          face = BlockFace.DOWN;
        }

        // Apply a force-update to the attached block
        Block atch = sm.getBlock().getRelative(face);
        BlockState stat = atch.getState(); // Store the state, to reapply it
        // Trying to update with no changes does nothing, so we first set it to air, with no physics (so that the mechanism doesn't come off).
        atch.setTypeId(0, false);
        stat.update(true);
      }
    }

    // set teams as started
    for (AutoRefTeam team : getTeams())
      team.startMatch();

    if (specSpawn != null)
      for (Player spec : this.getSpectators())
        if (this.inStartRegion(spec.getLocation()))
          spec.teleport(specSpawn.getLocation());

    for (Player spec : this.getSpectators())
    {
      spec.getInventory().setItem(0, new ItemStack(SpectatorListener.ToolAction.SPECTATOR_TELEPORT.tooltype));
      spec.getInventory().setItem(1, new ItemStack(SpectatorListener.ToolAction.SPECTATOR_CYCLE.tooltype));
    }

    // set the current state to playing
    setCurrentState(MatchStatus.PLAYING);

    // match minute timer
    AutoReferee plugin = AutoReferee.getInstance();
    clockTask = new MatchClockTask();
    clockTask.runTaskTimer(plugin, 60 * 20L, 60 * 20L);

    if (plugin.playedMapsTracker != null)
      plugin.playedMapsTracker.increment(normalizeMapName(this.getMapName()));
  }

  private static final Set<Long> announceMinutes =
    Sets.newHashSet(60L, 30L, 10L, 5L, 4L, 3L, 2L, 1L);

  // handle to the clock task
  protected MatchClockTask clockTask;

  protected class MatchClockTask extends BukkitRunnable
  {
    public void run()
    {
      AutoRefMatch match = AutoRefMatch.this;

      if (match.hasTimeLimit())
      {
        long minutesRemaining = match.getTimeRemaining() / 60L;
        if (minutesRemaining == 0L)
        {
          String timelimit = (match.getTimeLimit() / 60L) + " min";
          match.addEvent(new TranscriptEvent(match, TranscriptEvent.EventType.MATCH_END,
            "Match time limit reached: " + timelimit, null));
          match.endMatch();
        }
        else if (AutoRefMatch.announceMinutes.contains(minutesRemaining))
          match.broadcast(">>> " + ChatColor.GREEN +
            "Match ends in " + minutesRemaining + "m");
      }

      // send clock updates to ensure that client hud stays sync'd
      messageReferees("match", getWorld().getName(), "time", getTimestamp(","));

      if (lockTime) primaryWorld.setTime(startClock);
      AutoRefMatch.this.checkWinConditions();
    }
  }

  private int getVanishLevel(Player p)
  {
    // if this person is a player, lowest vanish level
    if (isPlayer(p)) return 0;

    // streamers are ONLY able to see streamers and players
    if (isStreamer(p)) return 1;

    // referees have the highest vanish level (see everything)
    if (isReferee(p)) return 200;

    // spectators can only be seen by referees
    return 100;
  }

  // either vanish or show the player `subj` from perspective of `view`
  protected void setupVanish(Player view, Player subj)
  {
    if (isSpectator(subj) && getSpectator(subj).isInvisible()) view.hidePlayer(subj);
    if (getVanishLevel(view) < getVanishLevel(subj) &&
      this.getCurrentState().inProgress()) view.hidePlayer(subj);
    else view.showPlayer(subj);
  }

  /**
   * Reconfigures spectator mode for all connected players.
   */
  public void setupSpectators()
  { for ( Player pl : getWorld().getPlayers() ) setupSpectators(pl); }

  /**
   * Reconfigures spectator mode for a single player. Useful for updating all
   * players when one player logs in.
   *
   * @param player player to configure spectator mode for
   */
  public void setupSpectators(Player player)
  {
    if (getCurrentState().isBeforeMatch()) setSpectatorMode(player, isReferee(player) || isPreviewMode());
    else setSpectatorMode(player, !isPlayer(player) || getCurrentState().isAfterMatch());

    // redo visibility
    setupVisibility(player);

    // if this player is a spectator
    if (isSpectator(player))
    {
      // apply night vision if necessary
      AutoRefSpectator s = getSpectator(player);
      if (s.hasNightVision()) s.applyNightVision();
    }
  }

  /**
   * Reconfigures visibility, to and from the specified player.
   */
  public void setupVisibility(Player player)
  {
    for ( Player x : getWorld().getPlayers() )
    {
      // setup vanish in both directions
      setupVanish(player, x);
      setupVanish(x, player);
    }
  }

  /**
   * Sets whether a specified player is in spectator mode, explicitly setting gamemode.
   *
   * @param player player to set spectator mode for
   * @param spec true to set spectator mode on, false to set spectator mode off
   */
  public void setSpectatorMode(Player player, boolean spec)
  {
    PlayerUtil.setSpectatorSettings(player, spec, this.gamemode);
    player.setScoreboard(spec ? getInfoboard() : getScoreboard());
    for (AutoRefTeam team : getTeams()) team.updateObjectives();

    if (!player.getAllowFlight()) player.setFallDistance(0.0f);
    SportBukkitUtil.setAffectsSpawning(player, !spec);

    boolean noEntityCollide = spec && getCurrentState().inProgress();
    SportBukkitUtil.setCollidesWithEntities(player, !noEntityCollide);
  }

  /**
   * Removes unprotected entities from the world.
   */
  public void clearEntities()
  {
    for (Entity e : primaryWorld.getEntitiesByClasses(Projectile.class, Item.class,
        Monster.class, Animals.class, Ambient.class, ExperienceOrb.class))
      if (!protectedEntities.contains(e.getUniqueId())) e.remove();
  }

  /**
   * Checks if the match start countdown is running.
   *
   * @return true if the countdown is in progress, otherwise false
   */
  public boolean isCountdownRunning()
  { return matchStarter != null; }

  /**
   * Cancels the match countdown in progress.
   */
  public void cancelCountdown()
  {
    if (isCountdownRunning()) matchStarter.cancel();
    matchStarter = null;
  }

  // helper class for starting match, synchronous task
  private static class CountdownTask extends BukkitRunnable
  {
    public static final ChatColor COLOR = ChatColor.GREEN;
    private int remainingSeconds = 3;

    private AutoRefMatch match = null;
    private boolean start = false;

    public CountdownTask(AutoRefMatch m, int time, boolean start)
    {
      match = m;
      remainingSeconds = time;
      this.start = start;
    }

    public void run()
    {
      if (remainingSeconds <= 0)
      {
        // setup world to go!
        if (this.start) match._startMatch();
        match.broadcast(">>> " + CountdownTask.COLOR + "GO!");

        // cancel the task
        match.cancelCountdown();
      }
      else if (remainingSeconds <= 3)
      {
        // report number of seconds remaining
        match.broadcast(
            ">>> " + CountdownTask.COLOR + Integer.toString(remainingSeconds) + "...");
      }

      // count down
      --remainingSeconds;
    }
  }

  // prepare this world to start
  public void startMatch(MatchStartEvent.Reason reason)
  {
    // match has already started, don't try to start it again
    if (!this.getCurrentState().isBeforeMatch()) return;

    MatchStartEvent event = new MatchStartEvent(this, reason);
    AutoReferee.callEvent(event);
    if (!refereeReady && event.isCancelled()) return;

    // nothing to do if the countdown is running
    if (isCountdownRunning()) return;

    // update all the objectives
    for (AutoRefTeam team : getTeams())
      team.updateObjectives();

    // set the current time to the start time
    primaryWorld.setTime(this.startClock);

    // remove all mobs, animals, and items
    this.clearEntities();

    // turn off weather forever (or for a long time)
    primaryWorld.setStorm(false);
    primaryWorld.setWeatherDuration(Integer.MAX_VALUE);

    // prepare all players for the match
    for (AutoRefPlayer apl : getPlayers()) apl.heal();

    // announce the match starting in X seconds
    int readyDelay = this.getReadyDelay();
    this.broadcast(CountdownTask.COLOR + "Match will begin in "
      + ChatColor.WHITE + Integer.toString(readyDelay) + CountdownTask.COLOR + " seconds.");

    // send referees countdown notification
    messageReferees("match", getWorld().getName(), "countdown", Integer.toString(readyDelay));
    startCountdown(readyDelay, true);

    // save a copy of the map image quickly before the match starts...
    saveMapImage();

    // TODO put this behind a config option
    itemElevatorDetectionTask = new ItemElevatorDetectionTask();
    itemElevatorDetectionTask.runTaskTimer(AutoReferee.getInstance(),
      0L, ItemElevatorDetectionTask.INTERVAL);
  }

  /**
   * Starts a countdown.
   *
   * @param delay number of seconds before end of countdown
   * @param start true if countdown should start match, otherwise false
   */
  public void startCountdown(int delay, boolean start)
  {
    // cancel any previous match-start task
    this.cancelCountdown();

    // schedule the task to announce and prepare the match
    this.matchStarter = new CountdownTask(this, delay, start);
    this.matchStarter.runTaskTimer(AutoReferee.getInstance(), 0L, 20L);
  }

  /**
   * Checks if teams have any players missing and are ready to play.
   */
  public void checkTeamsReady()
  {
    // this function is only useful if called prior to the match
    if (!getCurrentState().isBeforeMatch()) return;

    // if there are no players on the server
    if (getPlayers().isEmpty())
    {
      // set all the teams to not ready and status as waiting
      for ( AutoRefTeam t : teams ) t.setReady(false);
      setCurrentState(MatchStatus.WAITING); return;
    }

    // check if all the players are here
    boolean ready = true;
    for ( String name : getExpectedPlayers() )
    {
      OfflinePlayer opl = Bukkit.getOfflinePlayer(name);
      ready &= opl.isOnline() && isPlayer(opl.getPlayer()) &&
        getPlayer(opl.getPlayer()).isPresent();
    }

    // set status based on whether the players are online
    setCurrentState(ready ? MatchStatus.READY : MatchStatus.WAITING);
  }

  /**
   * Checks if teams and referees are ready for the match to start.
   */
  public void checkTeamsStart()
  {
    boolean teamsReady = true;
    for ( AutoRefTeam t : teams )
      teamsReady &= t.isReady();

    boolean ready = getReferees().size() == 0 ? teamsReady : isRefereeReady();
    if (teamsReady && !ready) for (Player p : getReferees())
      p.sendMessage(ChatColor.GRAY + "Teams are ready. Type /ready to begin the match.");

    // everyone is ready, let's go!
    if (ready) this.startMatch(MatchStartEvent.Reason.READY);
  }

  /**
   * Checks if any team has satisfied the win conditions.
   */
  public void checkWinConditions()
  {
    Plugin plugin = AutoReferee.getInstance();
    plugin.getServer().getScheduler().runTask(plugin,
      new Runnable(){ public void run(){ _checkWinConditions(); } });
  }

  private void _checkWinConditions()
  {
    if (!getCurrentState().inProgress())
    { return; }

    Set<AutoRefTeam> winningTeams = Sets.newHashSet();
    for (AutoRefTeam team : this.teams)
    {
      // pass this information along to the scoreboard
      team.updateObjectives();

      // if there are no win conditions set, skip this team
      if (team.getTeamGoals().size() == 0) continue;

      // check all win condition blocks (AND together)
      boolean win = true;
      for (AutoRefGoal goal : team.getTeamGoals())
        win &= goal.isSatisfied(this);

      // force an update of objective status
      team.updateBlockGoals();

      // if the team won, mark the match as completed
      if (win) winningTeams.add(team);
    }
    // if there is one "winning" team, they win
    if (winningTeams.size() == 1)
      endMatch(Iterables.getOnlyElement(winningTeams));

    // if we are just waiting for this match to end, check always
    else if (currentlyTied) endMatch();
  }

  // helper class for terminating world, synchronous task
  private class MatchUnloadTask extends BukkitRunnable
  {
    public void run()
    { destroy(MatchUnloadEvent.Reason.COMPLETE); }
  }

  private static class TiebreakerComparator implements Comparator<AutoRefTeam>
  {
    public int compare(AutoRefTeam a, AutoRefTeam b)
    {
      // break ties based on goal scores (FIXME)
      return (int) Math.signum(b.getObjectiveScore() - a.getObjectiveScore());
    }
  }

  /**
   * Ends match, allowing AutoReferee to break ties according to its own policies.
   */
  public void endMatch()
  {
    TiebreakerComparator cmp = new TiebreakerComparator();
    List<AutoRefTeam> sortedTeams = Lists.newArrayList(getTeams());

    // sort the teams based on their "score"
    Collections.sort(sortedTeams, cmp);

    if (0 != cmp.compare(sortedTeams.get(0), sortedTeams.get(1)))
    { endMatch(sortedTeams.get(0)); return; }

    if (AutoRefMatch.areTiesAllowed()) { endMatch(null); return; }

    if (currentlyTied) return;
    currentlyTied = true;

    // let the console know that the match cannot be ruled upon
    AutoReferee.log("Match tied. Deferring to referee intervention...");

    for (Player ref : getReferees())
    {
      ref.sendMessage(ChatColor.DARK_GRAY + "This match is currently tied.");
      ref.sendMessage(ChatColor.DARK_GRAY + "Use '/autoref endmatch <team>' to declare a winner.");
    }
    if (clockTask != null) clockTask.cancel();
  }

  /**
   * Ends match in favor of a specified team.
   *
   * @param team winning team, or null for no winner
   */
  public void endMatch(AutoRefTeam team)
  {
    MatchCompleteEvent event = new MatchCompleteEvent(this, team);
    AutoReferee.callEvent(event);
    if (event.isCancelled()) return;

    AutoReferee plugin = AutoReferee.getInstance();

    // update winner from the match complete event
    team = event.getWinner();

    // announce the victory and set the match to completed
    if (team != null) this.broadcast(team.getDisplayName() + " Wins!");
    else this.broadcast("Match terminated!");

    // don't have to delay this anymore :)
    clearEntities();

    for (AutoRefPlayer apl : getPlayers())
    {
      Player pl = apl.getPlayer();
      if (pl == null) continue;
      pl.getInventory().clear();
    }

    // update the client clock to ensure it syncs with match summary
    messageReferees("match", getWorld().getName(), "time", getTimestamp(","));
    this.recordedTime = this.getElapsedSeconds();

    // send referees the end event
    if (team != null) messageReferees("match", getWorld().getName(), "end", team.getName());
    else messageReferees("match", getWorld().getName(), "end");

    // reset and report kill streaks
    for (AutoRefPlayer apl : getPlayers()) apl.resetKillStreak();

    String winner = team == null ? "" : (" " + team.getName() + " wins!");
    addEvent(new TranscriptEvent(this, TranscriptEvent.EventType.MATCH_END, "Match ended." + winner, null));
    setCurrentState(MatchStatus.COMPLETED);

    setWinningTeam(team);
    logPlayerStats();

    if (clockTask != null) clockTask.cancel();

    int termDelay = plugin.getConfig().getInt(
      "delay-seconds.completed", COMPLETED_SECONDS);

    if (plugin.getLobbyWorld() != null)
      new MatchUnloadTask().runTaskLater(plugin, termDelay * 20L);

    if (itemElevatorDetectionTask != null) itemElevatorDetectionTask.cancel();
    itemElevatorDetectionTask = null;

    // set the time to day
    getWorld().setTime(0L);
  }

  /**
   * Finds a team whose name matches the given string.
   *
   * @param name team name to look up, either custom team name or base team name
   * @return team object matching the name if one exists, otherwise null
   */
  public AutoRefTeam getTeam(String name)
  {
    AutoRefTeam mteam = null;
    int bsz = 0;

    // if there is no match on that world, forget it
    // is this team name a word?
    for (AutoRefTeam t : teams)
    {
      // get the "match size"
      int msz = t.matches(name);

      // update the best match (null if multiple matches)
      if (msz > bsz) { mteam = t; bsz = msz; }
      else if (msz == bsz) mteam = null;
    }

    // return the matched team (or null if no match)
    return mteam;
  }

  /**
   * Finds a team whose scoreboard team name matches the given string.
   *
   * @param name scoreboard team name to look up
   * @return team object matching the name if one exists, otherwise null
   */
  public AutoRefTeam getScoreboardTeam(String name)
  {
    for (AutoRefTeam t : teams)
      if (name.equalsIgnoreCase(t.getScoreboardTeamName()))
        return t;
    return null;
  }

  Set<String> expectedPlayers = Sets.newHashSet();

  public Set<String> getExpectedPlayers()
  {
    Set<String> eps = Sets.newHashSet(expectedPlayers);
    for (AutoRefTeam team : teams)
      eps.addAll(team.getExpectedPlayers());
    return eps;
  }

  protected Map<String, String> playerCapes = Maps.newHashMap();

  public void addCape(String name, String cape)
  { playerCapes.put(name.toLowerCase(), cape); }

  /**
   * Adds a player to the list of expected players, without a team affiliation.
   */
  public void addExpectedPlayer(OfflinePlayer opl)
  { expectedPlayers.add(opl.getName().toLowerCase()); }

  /**
   * Gets the team the specified player is expected to join.
   *
   * @return team player is expected to join, otherwise null
   */
  public AutoRefTeam expectedTeam(OfflinePlayer opl)
  {
    String name = opl.getName().toLowerCase();
    for (AutoRefTeam team : teams)
      if (team.getExpectedPlayers().contains(name)) return team;
    return null;
  }

  /**
   * Checks if the specified player is expected to join this match.
   *
   * @return true if player is expected, otherwise false
   */
  public boolean isPlayerExpected(OfflinePlayer opl)
  { return getExpectedPlayers().contains(opl.getName().toLowerCase()); }

  /**
   * Removes a specified player from any expected player lists for this match.
   */
  public void removeExpectedPlayer(OfflinePlayer opl)
  {
    String name = opl.getName().toLowerCase();
    for (AutoRefTeam t : teams)
      t.getExpectedPlayers().remove(name);
    expectedPlayers.remove(name);
  }

  /**
   * Teleports a player to a match they have been added to, joining the team inviting them.
   */
  public void joinMatch(Player player)
  {
    PlayerMatchJoinEvent event = new PlayerMatchJoinEvent(player, this);
    AutoReferee.callEvent(event);
    if (event.isCancelled()) return;

    // if already here, skip this
    if (this.isPlayer(player)) return;

    // if this player needs to be placed on a team, go for it
    AutoRefTeam team = this.expectedTeam(player);
    if (team != null) this.joinTeam(player,
      team, PlayerTeamJoinEvent.Reason.EXPECTED, false);

    // otherwise, get them into the world
    else if (player.getWorld() != this.getWorld())
      player.teleport(this.getPlayerSpawn(player));

    // remove name from all lists
    this.removeExpectedPlayer(player);
    this.checkTeamsReady();
  }

  /**
   * Adds a player to the specified team. Removes the player from any other teams first,
   * if necessary. Roster changes are restricted while a match is in progress, unless forced.
   *
   * @param player player to be added to team
   * @param team team to add player to
   * @param force should player be added to team, even if match is in progress
   * @return true if player was added to team, otherwise false
   */
  public boolean joinTeam(Player player, AutoRefTeam team, PlayerTeamJoinEvent.Reason reason, boolean force)
  {
    AutoRefTeam pteam = getPlayerTeam(player);
    if (team == pteam) return true;

    if (pteam != null) pteam.leave(player, force);
    return team.join(player, reason, force);
  }

  /**
   * Removes player from all teams.
   *
   * @param player player to be removed
   * @param force should player be removed, even if match is in progress
   */
  public void leaveTeam(Player player, boolean force)
  { for (AutoRefTeam team : teams) team.leave(player, force); }

  private List<String> sortedPlayers;

  protected void updatePlayerList()
  {
    sortedPlayers = Lists.newLinkedList();
    for (AutoRefPlayer apl : getPlayers())
      sortedPlayers.add(apl.getName());
    Collections.sort(sortedPlayers);
  }

  protected String getCycleNextPlayer(String name)
  { return getCycleRelativePlayer(name, +1); }

  protected String getCyclePrevPlayer(String name)
  { return getCycleRelativePlayer(name, -1); }

  private String getCycleRelativePlayer(String name, int z)
  {
    if (name == null) return sortedPlayers.get(0);
    int k = Collections.binarySearch(sortedPlayers, name);

    int len = sortedPlayers.size();
    return sortedPlayers.get((k + len + z) % len);
  }

  public enum RespawnMode
  { ALLOW, BEDS_ONLY, DISALLOW }

  private RespawnMode respawnMode = RespawnMode.ALLOW;

  public RespawnMode getRespawnMode()
  { return respawnMode; }

  public void setRespawnMode(RespawnMode mode)
  { this.respawnMode = mode; }

  /**
   * Eliminates player from the match.
   */
  public void eliminatePlayer(Player player)
  {
    AutoRefTeam team = getPlayerTeam(player);
    if (team == null) return;

    String name = this.getDisplayName(player);
    if (!team.leaveQuietly(player)) return;

    this.broadcast(name + " has been eliminated!");
    if (!this.playersBecomeSpectators) this.ejectPlayer(player);
    this.checkWinConditions();
  }

  /**
   * Gets AutoRefPlayer object associated with a given player.
   *
   * @param name player name
   * @return matching AutoRefPlayer object, or null if no match
   */
  public AutoRefPlayer getPlayer(String name)
  {
    AutoRefPlayer bapl = null;
    if (name != null)
    {
      int score, b = Integer.MAX_VALUE;
      for (AutoRefPlayer apl : getPlayers())
      {
        score = apl.nameSearch(name);
        if (score < b) { b = score; bapl = apl; }
      }
    }
    return bapl;
  }

  /**
   * Gets AutoRefPlayer object associated with a given player.
   *
   * @return matching AutoRefPlayer object, or null if no match
   */
  public AutoRefPlayer getPlayer(OfflinePlayer player)
  { return player == null ? null : getPlayer(player.getName()); }

  /**
   * Checks if the specified player is on a team
   *
   * @return true if player is on a team, otherwise false
   */
  public boolean isPlayer(OfflinePlayer pl)
  { return getPlayer(pl) != null; }

  /**
   * Gets the player nearest to a specified location.
   *
   * @return player object for closest player, or null if no players
   */
  public AutoRefPlayer getNearestPlayer(Location loc)
  {
    AutoRefPlayer apl = null;
    double distance = Double.POSITIVE_INFINITY;

    for (AutoRefPlayer a : getPlayers())
    {
      Player pl = a.getPlayer();
      if (pl == null || pl.getWorld() != loc.getWorld()) continue;

      double d = loc.distanceSquared(pl.getLocation());
      if (d < distance) { apl = a; distance = d; }
    }

    return apl;
  }

  /**
   * Gets the team for a specified player.
   *
   * @return associated team object if one exists, otherwise null
   */
  public AutoRefTeam getPlayerTeam(Player player)
  {
    for (AutoRefTeam team : teams)
      if (team.getPlayer(player) != null) return team;
    return null;
  }

  /**
   * Gets colored player name for a specified player.
   *
   * @return colored player name
   */
  public String getDisplayName(Player player)
  {
    AutoRefPlayer apl = getPlayer(player);
    return (apl == null) ? player.getName() : apl.getDisplayName();
  }

  /**
   * Gets spawn location for the specified player, based on team.
   *
   * @return team-specific spawn location, or world spawn if not set
   */
  public Location getPlayerSpawn(Player player)
  {
    AutoRefTeam team = getPlayerTeam(player);
    if (team != null) return team.getSpawnLocation();

    boolean useWorldSpawn = getCurrentState().isBeforeMatch();
    return useWorldSpawn ? this.getWorldSpawn() : this.getSpectatorSpawn();
  }

  /**
   * Checks if a region is marked with a specific region flag.
   *
   * @return true if location contains flag, otherwise false
   */
  public boolean hasFlag(Location loc, AutoRefRegion.Flag flag)
  {
    // check start region flags
    if (inStartRegion(loc)) return getStartRegionFlags().contains(flag);

    boolean is = flag.defaultValue; Set<AutoRefRegion> regions = getRegions();
    if (regions != null) for ( AutoRefRegion reg : regions )
      if (reg.contains(loc)) { is = false; if (reg.is(flag)) return true; }
    return is;
  }

  private class MatchReportSaver extends BukkitRunnable
  {
    private File localStorage = null;
    private String webDirectory = null;

    public boolean serveLocally()
    { return webDirectory != null; }

    public MatchReportSaver()
    {
      String localDirectory = AutoReferee.getInstance().getConfig()
        .getString("local-storage.match-summary.directory", null);
      this.localStorage = localDirectory != null ? new File(localDirectory) :
        new File(AutoReferee.getInstance().getDataFolder(), "summary");

      if (!this.localStorage.exists())
        try { FileUtils.forceMkdir(this.localStorage); }
        catch (IOException e) { this.localStorage = null; }

      this.webDirectory = AutoReferee.getInstance().getConfig()
        .getString("local-storage.match-summary.web-directory", null);
    }

    public void run()
    {
      broadcastSync(ChatColor.RED + "Generating Match Summary...");
      String report = matchReportGenerator.generate(AutoRefMatch.this);

      MatchUploadStatsEvent event = new MatchUploadStatsEvent(AutoRefMatch.this, report);
      AutoReferee.callEvent(event);
      report = event.getWebstats();

      String webstats = null;
      if (!event.isCancelled())
      {
        if (this.localStorage != null)
        {
          String localFileID = new SimpleDateFormat("yyyy.MM.dd-HH.mm.ss").format(new Date()) + ".html";
          File localReport = new File(this.localStorage, localFileID);

          try
          {
            FileUtils.writeStringToFile(localReport, report);
            localReport.setReadable(true);
          }
          catch (IOException e) { e.printStackTrace(); }
          webstats = serveLocally() ? (webDirectory + localFileID) : uploadReport(report);
        }
        else webstats = uploadReport(report);
      }

      if (webstats == null) broadcastSync(ChatColor.RED + AutoReferee.NO_WEBSTATS_MESSAGE);
      else broadcastSync(ChatColor.RED + "Match Summary: " + ChatColor.RESET + webstats);
    }
  }

  private void logPlayerStats()
  {
    // upload WEBSTATS (do via an async query in case uploading the stats lags the main thread)
    new MatchReportSaver().runTaskAsynchronously(AutoReferee.getInstance());
  }

  private String uploadReport(String report)
  {
    String failure;
    try
    {
      // submit our request to pastehtml, get back a link to the report
      return QueryUtil.syncQuery("http://pastehtml.com/upload/create",
        "input_type=html&result=address&minecraft=1",
        "txt=" + URLEncoder.encode(report, "UTF-8"));
    }
    catch (IOException e) { failure = e.getLocalizedMessage(); }

    // somewhat quietly log the reason for the failed upload
    AutoReferee.log("Report upload failed: " + failure, Level.SEVERE);
    return null;
  }

  /**
   * Checks if a specified location is within the start region.
   *
   * @return true if location is inside start region, otherwise false
   */
  public boolean inStartRegion(Location loc)
  {
    if (getStartRegions() != null) for (AutoRefRegion reg : getStartRegions())
      if (reg.distanceToRegion(loc) < ZoneListener.SNEAK_DISTANCE) return true;
    return false;
  }

  public void updateCarrying(AutoRefPlayer apl, GoalsInventorySnapshot oldCarrying, GoalsInventorySnapshot newCarrying)
  {
    MapDifference<BlockData, Integer> diff = oldCarrying.getDiff(newCarrying);

    Player player = apl.getPlayer();
    // TODO send quantities too next protocol update
    for (BlockData bd : diff.entriesOnlyOnRight().keySet()) messageReferees("player", player.getName(), "goal", "+" + bd.serialize());
    for (BlockData bd : diff.entriesOnlyOnLeft().keySet()) messageReferees("player", player.getName(), "goal", "-" + bd.serialize());
  }

  public void updateHealthArmor(AutoRefPlayer apl, int oldHealth,
      int oldArmor, int newHealth, int newArmor)
  {
    Player player = apl.getPlayer();

    if (oldHealth != newHealth) messageReferees("player", player.getName(),
      "hp", Integer.toString(newHealth));

    if (oldArmor != newArmor) messageReferees("player", player.getName(),
      "armor", Integer.toString(newArmor));
  }

  /**
   * An event to be later reported in match statistics. Events are announced when they happen,
   * and each type has its own visibility level to denote who will see the even happen live.
   *
   * @author authorblues
   */
  public static class TranscriptEvent
  {
    // TODO: TEAM visibility would be nice
    public enum EventVisibility
    { NONE, REFEREES, TEAM, ALL }

    public enum EventType
    {
      // generic match start and end events
      MATCH_START("match-start", false, EventVisibility.NONE),
      MATCH_END("match-end", false, EventVisibility.NONE),

      // player messages (except kill streak) should be broadcast to players
      PLAYER_DEATH("player-death", true, EventVisibility.NONE),
      PLAYER_STREAK("player-killstreak", false, EventVisibility.NONE, ChatColor.DARK_GRAY),
      PLAYER_DOMINATE("player-dominate", true, EventVisibility.ALL, ChatColor.DARK_GRAY),
      PLAYER_REVENGE("player-revenge", true, EventVisibility.ALL, ChatColor.DARK_GRAY),

      // objective events should not be broadcast to the other team
      OBJECTIVE_FOUND("objective-found", true, EventVisibility.TEAM),
      OBJECTIVE_PLACED("objective-place", true, EventVisibility.TEAM),
      OBJECTIVE_DETAIL("objective-detail", true, EventVisibility.REFEREES),
      ;

      private String eventClass;
      private EventVisibility visibility;
      private ChatColor color;
      private boolean supportsFiltering;

      EventType(String eventClass, boolean hasFilter, EventVisibility visibility)
      { this(eventClass, hasFilter, visibility, null); }

      EventType(String eventClass, boolean hasFilter,
        EventVisibility visibility, ChatColor color)
      {
        this.eventClass = eventClass;
        this.visibility = visibility;
        this.color = color;
        this.supportsFiltering = hasFilter;
      }

      public String getEventClass()
      { return eventClass; }

      public String getEventName()
      { return StringUtils.capitalize(name().toLowerCase().replaceAll("_", " ")); }

      public EventVisibility getVisibility()
      { return visibility; }

      public ChatColor getColor()
      { return color; }

      public boolean hasFilter()
      { return supportsFiltering; }
    }

    private Set<Object> actors;
    public Set<Object> getActors()
    { return actors; }

    private Set<AutoRefPlayer> playerActors;

    public Set<AutoRefPlayer> getPlayerActors()
    { return playerActors; }

    private EventType type;

    public EventType getType()
    { return type; }

    private String message;

    public String getMessage()
    { return ChatColor.stripColor(message); }

    public String getColoredMessage()
    { return message; }

    private Location location;
    private long timestamp;

    /**
     *
     * Supported Actor types: AutoRefPlayer, BlockData
     *
     * @param match
     * @param type
     * @param message
     * @param loc
     * @param actors
     */
    public TranscriptEvent(AutoRefMatch match, EventType type, String message,
      Location loc, Object ...actors)
    {
      this.type = type;
      this.message = type.getColor() != null ? type.getColor() + message + ChatColor.RESET :
        message.contains("" + ChatColor.COLOR_CHAR) ? message : match.colorMessage(message);

      // if no location is given, use the spawn location
      this.location = (loc != null) ? loc :
        match.getWorld().getSpawnLocation();

      this.timestamp = match.getElapsedSeconds();

      this.actors = Sets.newHashSet(actors);
      this.playerActors = Sets.newHashSet();
      for (Object o : actors)
        if (o instanceof AutoRefPlayer)
          playerActors.add((AutoRefPlayer) o);
    }

    public String getTimestamp()
    {
      long t = getSeconds();
      return String.format("%02d:%02d:%02d",
        t/3600L, (t/60L)%60L, t%60L);
    }

    @Override
    public String toString()
    { return String.format("[%s] %s", this.getTimestamp(), this.getColoredMessage()); }

    public Location getLocation()
    { return location; }

    public long getSeconds()
    { return timestamp; }
  }

  /**
   * Adds an event to the match transcript. Announces the event to the appropriate recipients.
   *
   * @param event event to be added to the transcript
   */
  public void addEvent(TranscriptEvent event)
  {
    AutoReferee plugin = AutoReferee.getInstance();
    AutoReferee.callEvent(new MatchTranscriptEvent(this, event));
    transcript.add(event);

    Collection<Player> recipients = null;
    switch (event.getType().getVisibility())
    {
      case REFEREES: recipients = getReferees(false); break;
      case TEAM:
        recipients = getReferees(false);
        for (AutoRefPlayer p : event.getPlayerActors())
        {
          for (AutoRefPlayer teamPlayer : p.getTeam().getPlayers())
          { recipients.add(teamPlayer.getPlayer()); }
        }
        break;
      case ALL: recipients = getWorld().getPlayers(); break;
      case NONE: recipients = null; break;
      default: break;
    }

    String message = event.getColoredMessage();

    if (recipients != null) for (Player player : recipients)
      player.sendMessage(message);

    if (plugin.isConsoleLoggingEnabled())
    {
      if (plugin.isColoredConsoleLoggingEnabled())
        Bukkit.getConsoleSender().sendMessage("[AR] " + event.toString());
      else
        AutoReferee.log(event.toString());
    }
  }

  /**
   * Gets the current match transcript up to this point in time.
   *
   * @return immutable copy of the match transcript
   */
  public List<TranscriptEvent> getTranscript()
  { return Collections.unmodifiableList(transcript); }

  /**
   * Colors a message with team and objective colors. Prepares a message for broadcasting
   * to the chat, and should be used as a pre-processing step whenever a message needs to
   * be pretty-printed.
   *
   * @param message plain message
   * @return colored message
   */
  public String colorMessage(String message)
  {
    for (AutoRefPlayer apl : getPlayers()) if (apl != null)
      message = message.replaceAll(apl.getName(), apl.getDisplayName());
    return ChatColor.RESET + message;
  }

  // ABANDON HOPE, ALL YE WHO ENTER HERE!
  /**
   * Converts a human-readable time to a clock tick in Minecraft. Converts times such as "8am",
   * "1600", or "3:45p" to a valid clock tick setting that can be used to change the world time.
   *
   * @param time A string representing a human-readable time.
   * @return Equivalent clock tick
   */
  public static long parseTimeString(String time)
  {
    // "Some people, when confronted with a problem, think 'I know, I'll use
    // regular expressions.' Now they have two problems." -- Jamie Zawinski
    Pattern pattern = Pattern.compile("(\\d{1,5})(:(\\d{2}))?((a|p)m?)?", Pattern.CASE_INSENSITIVE);
    Matcher match = pattern.matcher(time);

    // if the time matches something sensible
    if (match.matches()) try
    {
      // get the primary value (could be hour, could be entire time in ticks)
      long prim = Long.parseLong(match.group(1));
      if (match.group(1).length() > 2 || prim > 24) return prim;

      // parse am/pm distinction (12am == midnight, 12pm == noon)
      if (match.group(5) != null)
        prim = ("p".equals(match.group(5)) ? 12 : 0) + (prim % 12L);

      // ticks are 1000/hour, group(3) is the minutes portion
      long ticks = prim * 1000L + (match.group(3) == null ? 0L :
          (Long.parseLong(match.group(3)) * 1000L / 60L));

      // ticks (18000 == midnight, 6000 == noon)
      return (ticks + 18000L) % 24000L;
    }
    catch (NumberFormatException ignored) {  }

    // default time: 6am
    return 0L;
  }

  // TODO make configurable
  private ItemStack customMatchInfoBook = null;

  /**
   * Gets the book given to players upon joining this match.
   */
  public ItemStack getMatchInfoBook()
  {
    if (this.customMatchInfoBook != null)
      return this.customMatchInfoBook.clone();

    ItemStack book = new ItemStack(Material.WRITTEN_BOOK, 1);
    BookMeta meta = (BookMeta) book.getItemMeta();

    meta.setTitle(ChatColor.RED + "" + ChatColor.BOLD + this.getMapName());
    meta.setAuthor(ChatColor.DARK_GRAY + this.getAuthorList());

    List<String> pages = Lists.newArrayList();

    // PAGE 1
    pages.add(BookUtil.makePage(
      BookUtil.center(ChatColor.BOLD + "[" + AutoReferee.getInstance().getName() + "]")
    ,  BookUtil.center(ChatColor.DARK_GRAY + this.getMapName())
    ,  BookUtil.center(" by " + ChatColor.DARK_GRAY + this.getAuthorList())
    ,  BookUtil.center("(v" + this.getMapVersion() + ")")
    ""
    ,  BookUtil.center(ChatColor.DARK_GRAY + "" + ChatColor.BOLD + "-- Teams --")
    ,  BookUtil.center(this.getTeamList())
    ""
    ,  ChatColor.BOLD + "Pg2." + ChatColor.RESET + " About the Map"
    ,  ChatColor.BOLD + "Pg3." + ChatColor.RESET + " About the Plugin"
    ));

    // PAGE 2
    pages.add(BookUtil.makePage(
      BookUtil.center(ChatColor.BOLD + "[" + AutoReferee.getInstance().getName() + "]")
    ,  BookUtil.center(ChatColor.DARK_GRAY + this.getMapName())
    ,  BookUtil.center(" by " + ChatColor.DARK_GRAY + this.getAuthorList())
    ,  BookUtil.center("(v" + this.getMapVersion() + ")")
    ""
        // TODO
    ,  BookUtil.center("Coming soon...")
    ));

    // PAGE 3
    pages.add(BookUtil.makePage(
      BookUtil.center(ChatColor.BOLD + "[" + AutoReferee.getInstance().getName() + "]")
    ""
    ,  ChatColor.BOLD + "/jointeam <team>"
    "  Join team"
    ""
    ,  ChatColor.BOLD + "/jointeam"
    "  Join random team"
    ""
    ,  ChatColor.BOLD + "/leaveteam"
    "  Leave current team"
    ""
    ,  ChatColor.BOLD + "/ready"
    "  Mark team as ready"
    ));

    meta.setPages(pages);
    book.setItemMeta(meta);
    return book;
  }

  public void giveMatchInfoBook(Player player, boolean force)
  {
    if (force || AutoRefMatch.giveMatchInfoBooks)
      player.getInventory().addItem(this.getMatchInfoBook());
  }

  public void giveMatchInfoBook(Player player)
  { this.giveMatchInfoBook(player, false); }

  /**
   * Send updated match information to a player.
   */
  public void sendMatchInfo(CommandSender sender)
  {
    sender.sendMessage(ChatColor.RESET + "Map: " + ChatColor.GRAY + getMapName() +
      " v" + getMapVersion() + ChatColor.ITALIC + " by " + getAuthorList());

    if (sender instanceof Player)
    {
      Player player = (Player) sender;
      AutoRefPlayer apl = getPlayer(player);
      String tmpflag = tmp ? "*" : "";

      if (apl != null && apl.getTeam() != null)
        player.sendMessage("You are on team: " + apl.getTeam().getDisplayName());
      else if (isReferee(player)) player.sendMessage(ChatColor.GRAY + "You are a referee! " + tmpflag);
      else player.sendMessage("You are not on a team! Type " + ChatColor.GRAY + "/jointeam");
    }

    for (AutoRefTeam team : getTeams())
      sender.sendMessage(String.format("%s (%d) - %s",
        team.getDisplayName(), team.getPlayers().size(), team.getPlayerList()));

    sender.sendMessage("Match status is currently " + ChatColor.GRAY + getCurrentState().name() +
      (this.getCurrentState().isBeforeMatch() ? (" [" + this.access.name() + "]") : ""));
    sender.sendMessage("Map difficulty is set to: " + ChatColor.GRAY + getWorld().getDifficulty().name());

    long timestamp = this.getElapsedSeconds(), timelimit = this.getTimeLimit();
    if (getCurrentState().inProgress()) sender.sendMessage(this.hasTimeLimit()
      ? String.format(ChatColor.GRAY + "The current match time is: " +
        "%02d:%02d:%02d / %02d:%02d:%02d", timestamp/3600L, (timestamp/60L)%60L, timestamp%60L,
        timelimit/3600L, (timelimit/60L)%60L, timelimit%60L)
      : String.format(ChatColor.GRAY + "The current match time is: " +
        "%02d:%02d:%02d", timestamp/3600L, (timestamp/60L)%60L, timestamp%60L));
  }
}
TOP

Related Classes of org.mctourney.autoreferee.AutoRefMatch$TranscriptEvent

TOP
Copyright © 2018 www.massapi.com. All rights reserved.
All source code are property of their respective owners. Java is a trademark of Sun Microsystems, Inc and owned by ORACLE Inc. Contact coftware#gmail.com.
y> ew');