shuffle.fwk.data.simulation.SimulationTask.java Source code

Java tutorial

Introduction

Here is the source code for shuffle.fwk.data.simulation.SimulationTask.java

Source

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

package shuffle.fwk.data.simulation;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.RecursiveTask;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.logging.Logger;

import org.apache.commons.lang3.StringUtils;

import shuffle.fwk.data.Board;
import shuffle.fwk.data.Effect;
import shuffle.fwk.data.PkmType;
import shuffle.fwk.data.Species;
import shuffle.fwk.data.simulation.effects.ActivateComboEffect;
import shuffle.fwk.data.simulation.effects.ActivateMegaComboEffect;
import shuffle.fwk.data.simulation.effects.ComboEffect;
import shuffle.fwk.data.simulation.effects.DelayThawEffect;
import shuffle.fwk.data.simulation.effects.EraseComboEffect;
import shuffle.fwk.data.simulation.util.NumberSpan;
import shuffle.fwk.data.simulation.util.TriFunction;

/**
 * @author Andrew Meyers
 *         
 */
public class SimulationTask extends RecursiveTask<SimulationState> {
    private static final long serialVersionUID = -7639294565196247487L;
    private static final Logger LOG = Logger.getLogger(SimulationTask.class.getName());
    private static boolean logFiner = false;
    /**
     * All sims will terminate if their curTimeStamp reaches this frame count.
     */
    private static final int SIM_TIMEOUT = 1000;
    private int simCounter = 0;

    private static final double[] COMBO_MULTIPLIER = new double[] { 1.0, 1.1, 1.15, 1.2, 1.3, 1.4, 1.5, 2, 2.5 };
    private static final int[] COMBO_THRESHOLD = new int[] { 1, 2, 5, 10, 25, 50, 75, 100, 200 };

    private static final int COMBO_DELAY = 24;
    private static final int THAW_DELAY = 1;

    private Integer lastGravityTime = null;
    private Integer nextBumpTime = 0;

    private int lastComboTime = -COMBO_DELAY;

    private boolean boardChanged = true;

    /**
     * The current time for the simulation.
     */
    private int curTimeStamp = 0;

    /**
     * The unique identification for this simulation.
     */
    private final String id;

    /**
     * The map of timestamp to a Collection of all scheduled effects for that timestamp (happens
     * before gravity checks/etc.)
     */
    private Map<Integer, Collection<ComboEffect>> simulationEffects = new HashMap<Integer, Collection<ComboEffect>>();
    private PriorityQueue<Integer> simulationEffectTimes = new PriorityQueue<Integer>();

    private HashMap<Integer, Collection<ActivateComboEffect>> effectClaims = new HashMap<Integer, Collection<ActivateComboEffect>>();
    private HashMap<Integer, Collection<ComboEffect>> activeEffects = new HashMap<Integer, Collection<ComboEffect>>();

    private List<BiFunction<ActivateComboEffect, SimulationTask, NumberSpan>> scoreModifiers = new ArrayList<BiFunction<ActivateComboEffect, SimulationTask, NumberSpan>>();
    private List<BiConsumer<ActivateComboEffect, SimulationTask>> finishedActions = new ArrayList<BiConsumer<ActivateComboEffect, SimulationTask>>();
    /**
     * The prospective combos that are available to activate.
     */
    private TreeSet<ActivateComboEffect> prospecticeCombosSet = new TreeSet<ActivateComboEffect>(
            (a, b) -> Integer.compare(a.getPriority(), b.getPriority()));

    private SimulationState state;

    private Consumer<SimulationState> finalAction = null;

    public SimulationTask(SimulationCore simulationCore) {
        this(simulationCore, null, new SimulationFeeder());
    }

    public SimulationTask(SimulationCore simulationCore, SimulationFeeder feeder) {
        this(simulationCore, null, feeder);
    }

    public SimulationTask(SimulationCore simulationCore, List<Integer> move, SimulationFeeder feeder) {
        String moveString;
        if (move == null) {
            moveString = "null";
        } else {
            moveString = StringUtils.join(move.toArray(new Integer[0]));
        }
        id = moveString + " feeder:" + feeder.getID().toString();
        createNewStateForMove(simulationCore, move, feeder);
    }

    public NumberSpan getScoreModifier(ActivateComboEffect comboEffect) {
        NumberSpan compoundMultiplier = new NumberSpan(1);
        for (BiFunction<ActivateComboEffect, SimulationTask, NumberSpan> modifier : scoreModifiers) {
            if (modifier != null) {
                NumberSpan multiplier = modifier.apply(comboEffect, this);
                if (multiplier != null) {
                    compoundMultiplier = compoundMultiplier.multiplyBy(multiplier);
                }
            }
        }
        PkmType type = getState().getSpeciesType(getEffectSpecies(comboEffect.getCoords()));
        Board.Status boardStatus = getState().getBoard().getStatus();
        return compoundMultiplier.multiplyBy(boardStatus.getMultiplier(type));
    }

    public void addScoreModifier(BiFunction<ActivateComboEffect, SimulationTask, NumberSpan> modifier) {
        scoreModifiers.add(modifier);
    }

    public void removeScoreModifier(BiFunction<ActivateComboEffect, SimulationTask, NumberSpan> modifier) {
        scoreModifiers.remove(modifier);
    }

    public void executeFinishedActions(ActivateComboEffect comboEffect) {
        if (finishedActions.isEmpty()) {
            return;
        }
        Collection<BiConsumer<ActivateComboEffect, SimulationTask>> toActivate = new ArrayList<BiConsumer<ActivateComboEffect, SimulationTask>>(
                finishedActions);
        finishedActions.clear();
        for (BiConsumer<ActivateComboEffect, SimulationTask> action : toActivate) {
            action.accept(comboEffect, this);
        }
    }

    public void addFinishedAction(BiConsumer<ActivateComboEffect, SimulationTask> action) {
        finishedActions.add(action);
    }

    public void removeFinishedAction(BiConsumer<ActivateComboEffect, SimulationTask> action) {
        finishedActions.remove(action);
    }

    public String getId() {
        return id;
    }

    public void logFinerWithId(String message, Object... args) {
        LOG.finer(String.format(id + ": " + message, args));
    }

    public static void setLogFiner(boolean enabled) {
        logFiner = enabled;
    }

    /**
     * Only used when starting a simulation task. This will initialize queues, processes, etc.
     * 
     * @param simulationCore
     * @param move
     * @param feeder
     */
    private void createNewStateForMove(SimulationCore simulationCore, List<Integer> move, SimulationFeeder feeder) {
        if (logFiner) {
            logFinerWithId("creating new state");
        }
        // Do the swap
        Board startBoard = simulationCore.getBoardCopy();
        if (move != null && move.size() >= 4) {
            Species pickedUp = startBoard.getSpeciesAt(move.get(0), move.get(1));
            Species droppedAt = startBoard.getSpeciesAt(move.get(2), move.get(3));
            startBoard.setSpeciesAt(move.get(0), move.get(1), droppedAt);
            startBoard.setSpeciesAt(move.get(2), move.get(3), pickedUp);
        }
        if (logFiner) {
            logFinerWithId("board created");
        }
        // Check for originality as non-air blocks.
        boolean[][] originality = new boolean[Board.NUM_ROWS][Board.NUM_COLS];
        for (int row = 1; row <= Board.NUM_ROWS; row++) {
            for (int col = 1; col <= Board.NUM_COLS; col++) {
                originality[row - 1][col - 1] = !startBoard.getSpeciesAt(row, col).equals(Species.AIR);
            }
        }
        if (logFiner) {
            logFinerWithId("originality set");
        }

        // Create the state
        state = new SimulationState(simulationCore, feeder, startBoard, 1.0f, new NumberSpan(), 0, originality, 0);
        if (logFiner) {
            logFinerWithId("state made");
        }

        doComboCheck();
        if (logFiner) {
            logFinerWithId("combos checked, number of claims: " + prospecticeCombosSet.size());
        }
        if (move != null && move.size() >= 4) {
            ActivateComboEffect firstCombo = findBestComboFor(move.get(2), move.get(3));
            if (firstCombo == null) {
                firstCombo = findBestComboFor(move.get(0), move.get(1));
            }
            if (logFiner) {
                logFinerWithId("performing FIRST combo: " + StringUtils.join(firstCombo) + " with species: "
                        + getEffectSpecies(firstCombo.getCoords()));
            }
            List<Integer> metalBlocks = findMatches(Board.NUM_CELLS, true, (r, c, s) -> s.getNextMetal().isAir());
            Board b = getState().getBoard();
            // Advance blocks that are not erasing entirely
            for (int row = 1; row <= Board.NUM_ROWS; row++) {
                for (int col = 1; col <= Board.NUM_COLS; col++) {
                    Species cur = b.getSpeciesAt(row, col);
                    if (getEffectFor(cur).equals(Effect.METAL)) {
                        Species next = Species.getNextMetal(cur);
                        if (!next.isAir()) {
                            b.setSpeciesAt(row, col, Species.getNextMetal(cur));
                        }
                    }
                }
            }
            if (metalBlocks.size() > 0) {
                // and set in an erasure effect for all the blocks that would fully erase this turn.
                EraseComboEffect metalEffect = new EraseComboEffect(metalBlocks);
                metalEffect.setForceErase(true);
                EraseComboEffect woodShatter = getWoodShatterEffect(metalEffect);
                if (woodShatter != null) {
                    scheduleEffect(woodShatter, Effect.WOOD.getErasureDelay());
                }
                scheduleEffect(metalEffect, Effect.getDefaultErasureDelay());
            }
            // Before the first combo, decrement the status counter by 1 if it is not none.
            boolean wasMega = getState().isMegaActive();
            doCombo(firstCombo);
            doGravity();
            if (!b.getStatus().isNone() && (getState().isMegaActive() == wasMega)) {
                // afflicted by status, and mega state unchanged, decrease by one.
                b.decreaseStatusDuration(1);
            }
            // After the first combo, if the status counter is 0, set it to none.
            if (b.getStatusDuration() == 0) {
                b.setStatus(Board.Status.NONE);
            }
        }
    }

    private ActivateComboEffect findBestComboFor(int row, int col) {
        ActivateComboEffect firstCombo = null;
        for (ActivateComboEffect effect : prospecticeCombosSet) {
            if (effect.containsCoords(row, col)) {
                firstCombo = effect;
                break;
            }
        }
        return firstCombo;
    }

    @Override
    protected SimulationState compute() {
        // ScheduledEffects should start out with exactly one effect on the queue.
        try {
            while (!doneSimulation() && simCounter < SIM_TIMEOUT) {
                if (logFiner) {
                    logFinerWithId("simtime: %s, score: %s, comboQueue:%s", curTimeStamp, getState().getScore(),
                            prospecticeCombosSet.size());
                }
                doGravity();
                doAllCurrentEffects();
                doGravity();
                if (boardChanged) {
                    doComboCheck();
                    boardChanged = false;
                }
                doBestCombo();
                advanceTimeStamp();
                if (onlyThawing()) {
                    getState().setChainPause();
                    scoreModifiers.clear();
                }
                simCounter++; // Loop protection
            }
            if (finalAction != null) {
                finalAction.accept(getState());
            }
            return getState();
        } catch (Exception e) {
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            e.printStackTrace(pw);
            sw.toString();
            LOG.severe("Something happened: " + e.getMessage() + " " + sw.toString());
            return null;
        }
    }

    /**
     * Checks if the only thing happening is a 'thawing' action. If so, then the chain count is set
     * to 0. "only thing happening is a 'thawing' action" means: <br>
     * <ul>
     * <li>There is no block in free-fall.</li>
     * <li>There are no combos waiting to activate.</li>
     * <li>There are no in-progress combos.</li>
     * <li>The only in-progress effect is a DelayThawEffect</li>
     * </ul>
     * 
     * @return
     */
    private boolean onlyThawing() {
        boolean inactive = getNextBumpTime() == null && getNextComboTime() == null;
        boolean isThawing = false;
        if (inactive) {
            for (Collection<ComboEffect> collection : simulationEffects.values()) {
                for (ComboEffect effect : collection) {
                    if (effect instanceof DelayThawEffect) {
                        isThawing = true;
                    } else {
                        inactive = false;
                    }
                }
            }
        }
        return inactive && isThawing;
    }

    private void advanceTimeStamp() {
        Integer nextEffectTime = getNextEffectTime();
        Integer nextBumpTime = getNextBumpTime();
        Integer nextComboTime = getNextComboTime();

        Integer nextTime = getLowestOf(nextEffectTime, nextBumpTime, nextComboTime);
        if (nextTime != null) {
            curTimeStamp = nextTime.intValue();
        }
    }

    private Integer getLowestOf(Integer... values) {
        Integer lowest = null;
        for (Integer i : values) {
            if (lowest == null || i != null && i.intValue() < lowest.intValue()) {
                lowest = i;
            }
        }
        return lowest;
    }

    private boolean doneSimulation() {
        boolean ret = true;
        ret &= getNextEffectTime() == null;
        ret &= getNextBumpTime() == null;
        ret &= getNextComboTime() == null;
        return ret;
    }

    /**
     * @return
     */
    private Integer getNextEffectTime() {
        return simulationEffectTimes.peek();
    }

    public Integer getNextComboTime() {
        if (prospecticeCombosSet.isEmpty()) {
            return null;
        } else {
            return Math.max(lastComboTime + COMBO_DELAY, curTimeStamp);
        }
    }

    public Integer getNextBumpTime() {
        return nextBumpTime;
    }

    private void doAllCurrentEffects() {
        Integer nextTime = getNextEffectTime();
        if (nextTime != null && nextTime.intValue() <= curTimeStamp) {
            Collection<ComboEffect> currentEffects = popCurrentEffects();
            boardChanged |= !currentEffects.isEmpty();
            for (ComboEffect effect : currentEffects) {
                effect.doEffect(this);
            }
        }
    }

    private void doGravity() {
        int lastTime = lastGravityTime == null ? curTimeStamp : lastGravityTime.intValue();
        int increment = curTimeStamp - lastTime;

        Integer minHeightToBump = moveEverythingDownBy(increment);

        nextBumpTime = minHeightToBump == null ? null : minHeightToBump.intValue() + curTimeStamp;
        if (nextBumpTime == null) { // If gravity is done,
            lastGravityTime = null; // then we de-activate gravity
        } else {
            lastGravityTime = curTimeStamp; // otherwise we keep track of when it last ticked
        }
    }

    /**
     * Returns the minimum height until the next bump. this will also set the boardChanged condition
     * to true if something bumps, and this will ALSO use the feeder (if available and ready) to fill
     * in according to the increment.
     * 
     * @param increment
     * @return
     */
    private Integer moveEverythingDownBy(int increment) {
        Integer minHeight = null;
        SimulationFeeder feeder = getState().getFeeder();
        for (int col = 1; col <= Board.NUM_COLS; col++) { // each column
            int[] initialHeight = getHeights(col);
            for (int row = Board.NUM_ROWS; row >= 1; row--) { // going upwards
                int toLowerBy = Math.min(increment, initialHeight[row - 1]);
                if (toLowerBy > 0 && canMove(row, col)) { // not frozen, not claimed by a combo, and not
                                                          // air
                                                          // find the distance to cover
                    int positionDelta = toLowerBy % SimulationState.FALL_DISTANCE;
                    int rowDelta = toLowerBy / SimulationState.FALL_DISTANCE;
                    // find the destination position and row
                    int startPosition = getState().getFallingPositionAt(row, col);
                    int destPos = startPosition - positionDelta;
                    int destRow = row + rowDelta;
                    while (destPos < 0) {
                        destPos += SimulationState.FALL_DISTANCE;
                        destRow += 1;
                    }
                    if (destRow != row || destPos != startPosition) {
                        // make the actual move
                        getState().swapTiles(row, col, destRow, col);
                        // we DID move, so we are 'falling'
                        getState().setFallingAt(destRow, col, true);
                        // set the appropriate position
                        getState().setFallingPositionAt(destRow, col, destPos);
                    }
                } else if (toLowerBy > 0 && row == 1 && feeder.hasMore(col)
                        && getState().getBoard().isAir(row, col)) {
                    // Feeder pushes into the very TOP row.
                    // How much we can actually put in
                    // how many rows we can actually add to - for every 16th additional unit of toLowerBy
                    // we gain 1 more row.
                    // Because this row is definitely free, we know we can add at least one, even if its
                    // at position 15.
                    // So we must go through and add as many as we can fit from the feeder.
                    int rowSpace = 1 + toLowerBy / SimulationState.FALL_DISTANCE;
                    int destPos = SimulationState.FALL_DISTANCE - toLowerBy % SimulationState.FALL_DISTANCE;

                    int fedRow = rowSpace;
                    while (fedRow >= 1 && feeder.hasMore(col)) {
                        Board b = getState().getBoard();
                        b.setSpeciesAt(fedRow, col, feeder.pollColumn(col));
                        b.setFrozenAt(fedRow, col, false);
                        // We fed something in, which is 'falling'
                        getState().setFallingAt(fedRow, col, true);
                        // set the appropriate position
                        getState().setFallingPositionAt(fedRow, col, destPos);
                        // go to the next row up
                        fedRow -= 1;
                    }
                }
            }
            // Maintain states and check for heights
            int[] postMoveHeight = getHeights(col);
            for (int row = Board.NUM_ROWS; row >= 1; row--) { // going upwards
                if (canMove(row, col)) { // not frozen, not claimed by a combo, and not air
                    int height = postMoveHeight[row - 1];
                    if (getState().isFallingAt(row, col) && height == 0) {
                        getState().setFallingAt(row, col, false);
                        boardChanged = true;
                    } else if (height > 0) {
                        getState().setFallingAt(row, col, true);
                        if (minHeight == null || minHeight.intValue() > height) {
                            minHeight = height;
                        }
                    }
                } else if (row == 1 && feeder.hasMore(col) && getState().getBoard().isAir(row, col)) {
                    int height = postMoveHeight[row - 1];
                    if (minHeight == null || minHeight.intValue() > height) {
                        minHeight = height;
                    }
                }
            }
        }
        return minHeight;
    }

    private int[] getHeights(int col) {
        Board b = getState().getBoard();
        int[] heightAt = new int[Board.NUM_ROWS];
        for (int row = Board.NUM_ROWS; row >= 1; row--) { // going upwards
            int belowPosition = 0;
            int belowHeight = 0;
            if (row < Board.NUM_ROWS && // this is above the bottom
            // And this block can conduct height (is inactive air or moving)
                    (canMove(row + 1, col) || b.isAir(row + 1, col) && !isActive(row, col))) {
                belowPosition = getState().getFallingPositionAt(row + 1, col);
                belowHeight = heightAt[row];
            }

            // Finally, set the height for this row, to be used while finding how far
            // to move each block downwards (and where to put them)
            int myPosition = getState().getFallingPositionAt(row, col);
            if (b.isAir(row, col) && !isActive(row, col)) {
                // If this is inactive air, then it has a full fall distance to add.
                myPosition = SimulationState.FALL_DISTANCE;
                // Air has a height of this amount when inactive, but always a position of 0
            }
            heightAt[row - 1] = belowHeight + myPosition - belowPosition;
        }
        return heightAt;
    }

    /**
     * Can the given coordinates move? That is, they aren't claimed, active, frozen, or air.
     * 
     * @param row
     * @param col
     * @return
     */
    private boolean canMove(int row, int col) {
        Board b = getState().getBoard();
        return !isActive(row, col) && !isClaimed(row, col) && !b.isFrozenAt(row, col)
                && b.getSpeciesAt(row, col).isFreezable();
    }

    private void doComboCheck() {
        Board b = getState().getBoard();
        boolean[][] hAvailable = new boolean[Board.NUM_ROWS][Board.NUM_COLS];
        boolean[][] vAvailable = new boolean[Board.NUM_ROWS][Board.NUM_COLS];
        boolean[][] isAvailable = new boolean[Board.NUM_ROWS][Board.NUM_COLS];
        // Find out what is available for combo
        for (int row = 1; row <= Board.NUM_ROWS; row++) {
            for (int col = 1; col <= Board.NUM_COLS; col++) {
                if (isFalling(row, col) || !isPickable(row, col)) {
                    // No falling or Air block may combo
                    continue;
                }
                if (isActive(row, col)) {
                    // All active non-falling blocks might be available for either combo direction if...
                    Collection<ActivateComboEffect> claims = getClaimsFor(row, col);
                    for (ActivateComboEffect effect : claims) {
                        // there is an effect in that direction which has not been activated yet
                        hAvailable[row - 1][col - 1] |= effect.isHorizontal();
                        vAvailable[row - 1][col - 1] |= !effect.isHorizontal();
                    }
                } else {
                    // All inactive non-falling blocks are available for either vertical or horizontal
                    // combos
                    hAvailable[row - 1][col - 1] = true;
                    vAvailable[row - 1][col - 1] = true;
                }
                isAvailable[row - 1][col - 1] = hAvailable[row - 1][col - 1] || vAvailable[row - 1][col - 1];
            }
        }
        // Then map out the exact lines
        int[][] hLines = new int[Board.NUM_ROWS][Board.NUM_COLS];
        int[][] vLines = new int[Board.NUM_ROWS][Board.NUM_COLS];
        // This will include the biggest current prospective combos, all of them
        // including possible extensions.
        for (int row = 1; row <= Board.NUM_ROWS; row++) {
            for (int col = 1; col <= Board.NUM_COLS; col++) {
                if (isAvailable[row - 1][col - 1]) {
                    Species cur = b.getSpeciesAt(row, col);
                    if (col > 1 && hAvailable[row - 1][col - 1] && hAvailable[row - 1][col - 2]) {
                        Species left = b.getSpeciesAt(row, col - 1);
                        if (cur.equals(left)) {
                            hLines[row - 1][col - 1] = hLines[row - 1][col - 2] + 1;
                            hLines[row - 1][col - 2] = 0;
                        }
                    }
                    if (row > 1 && vAvailable[row - 1][col - 1] && vAvailable[row - 2][col - 1]) {
                        Species above = b.getSpeciesAt(row - 1, col);
                        if (cur.equals(above)) {
                            vLines[row - 1][col - 1] = vLines[row - 2][col - 1] + 1;
                            vLines[row - 2][col - 1] = 0;
                        }
                    }
                }
            }
        }
        effectClaims.clear();
        prospecticeCombosSet.clear();
        // Finally, wipe out the prospective combos and all combo claims.
        // Then, reconstruct them from the grids made above.
        for (int row = 1; row <= Board.NUM_ROWS; row++) {
            for (int col = 1; col <= Board.NUM_COLS; col++) {
                int vRun = vLines[row - 1][col - 1];
                if (vRun >= 2) {
                    List<Integer> coords = new ArrayList<Integer>();
                    while (vRun >= 0) {
                        coords.addAll(Arrays.asList(row - vRun, col));
                        vRun -= 1;
                    }
                    addProspectiveCombo(coords);
                }

                int hRun = hLines[row - 1][col - 1];
                if (hRun >= 2) {
                    List<Integer> coords = new ArrayList<Integer>();
                    while (hRun >= 0) {
                        coords.addAll(Arrays.asList(row, col - hRun));
                        hRun -= 1;
                    }
                    addProspectiveCombo(coords);
                }
            }
        }

    }

    private boolean isPickable(int row, int col) {
        Board board = getState().getBoard();
        Species cur = board.getSpeciesAt(row, col);
        return getEffectFor(cur).isPickable();
    }

    public boolean isFalling(int row, int col) {
        return getState().isFallingAt(row, col);
    }

    private void doBestCombo() {
        Integer nextComboTime = getNextComboTime();
        if (nextComboTime != null && nextComboTime.intValue() <= curTimeStamp) {
            ActivateComboEffect effect = prospecticeCombosSet.pollFirst();
            doCombo(effect);
        }
    }

    public void doCombo(ActivateComboEffect effect) {
        if (effect != null) {
            if (logFiner) {
                logFinerWithId("Performing combo effect: %s", StringUtils.join(effect));
            }
            effect.doEffect(this);
            lastComboTime = curTimeStamp;
            boardChanged = true;
        }
    }

    private int getKeyForCoords(int row, int col) {
        return Board.NUM_COLS * (row - 1) + (col - 1);
    }

    public boolean isActive(int row, int col) {
        return activeEffects.containsKey(getKeyForCoords(row, col));
    }

    public boolean isActiveCombo(List<Integer> coords) {
        if (coords.size() >= 2) {
            Collection<ComboEffect> effects = activeEffects.get(getKeyForCoords(coords.get(0), coords.get(1)));
            if (effects != null) {
                for (ComboEffect collision : effects) {
                    if (collision.getCoords().equals(coords)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    public Collection<ComboEffect> getActiveEffectsFor(int row, int col) {
        Collection<ComboEffect> ret = Collections.emptyList();
        Integer key = getKeyForCoords(row, col);
        if (activeEffects.containsKey(key)) {
            ret = activeEffects.get(key);
        }
        return ret;
    }

    public void addActiveFor(ComboEffect effect) {
        List<Integer> coords = effect.getCoords();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            Integer row = coords.get(i * 2);
            Integer col = coords.get(i * 2 + 1);
            Integer key = getKeyForCoords(row, col);
            if (!activeEffects.containsKey(key)) {
                activeEffects.put(key, new HashSet<ComboEffect>());
            }
            activeEffects.get(key).add(effect);
        }
    }

    public void removeActive(ComboEffect effect) {
        List<Integer> coords = effect.getCoords();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            Integer row = coords.get(i * 2);
            Integer col = coords.get(i * 2 + 1);
            Integer key = getKeyForCoords(row, col);
            if (activeEffects.containsKey(key)) {
                activeEffects.get(key).remove(effect);
                if (activeEffects.get(key).isEmpty()) {
                    activeEffects.remove(key);
                }
            }
        }
    }

    /**
     * Returns true if the given coordinates are claimed by some recognized possible combo.
     * 
     * @param row
     * @param col
     * @return
     */
    public boolean isClaimed(int row, int col) {
        return effectClaims.containsKey(getKeyForCoords(row, col));
    }

    public Collection<ActivateComboEffect> getClaimsFor(int row, int col) {
        Collection<ActivateComboEffect> ret = Collections.emptyList();
        Integer key = getKeyForCoords(row, col);
        if (effectClaims.containsKey(key)) {
            ret = effectClaims.get(key);
        }
        return ret;
    }

    /**
     * Registers the given effect as 'claimed', i.e. about to match but not yet activated.
     * 
     * @param effect
     */
    public void addClaimFor(ActivateComboEffect effect) {
        List<Integer> coords = effect.getCoords();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            Integer row = coords.get(i * 2);
            Integer col = coords.get(i * 2 + 1);
            Integer key = getKeyForCoords(row, col);
            if (!effectClaims.containsKey(key)) {
                effectClaims.put(key, new HashSet<ActivateComboEffect>());
            }
            effectClaims.get(key).add(effect);
        }
    }

    /**
     * Just removes all claims for the given row and column. Does not affect prospectiveCombos.
     * 
     * @param row
     * @param col
     */
    public void removeClaimsFor(int row, int col) {
        effectClaims.remove(getKeyForCoords(row, col));
    }

    public void removeClaim(ActivateComboEffect effect) {
        List<Integer> coords = effect.getCoords();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            Integer row = coords.get(i * 2);
            Integer col = coords.get(i * 2 + 1);
            Integer key = getKeyForCoords(row, col);
            if (effectClaims.containsKey(key)) {
                effectClaims.get(key).remove(effect);
                if (effectClaims.get(key).isEmpty()) {
                    effectClaims.remove(key);
                }
            }
        }
        prospecticeCombosSet.remove(effect);
    }

    public void completeComboFor(EraseComboEffect effect) {
        if (logFiner) {
            logFinerWithId("Completing combo: %s", effect.toString());
        }
        List<Integer> coords = effect.getCoords();
        removeActive(effect);
        Board b = getState().getBoard();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            if (!isActive(row, col) && effect.shouldErase(row, col)) {
                // Handle statistics

                // Continue on with replacement
                if (b.isCloudedAt(row, col) && effect.isForceErase()) {
                    b.setClouded(row, col, false);
                    getState().addDisruptionCleared(1);
                } else if (b.isFrozenAt(row, col) || getEffectFor(b.getSpeciesAt(row, col)).isDisruption()) {
                    getState().addDisruptionCleared(1);
                }
                getState().addBlockCleared(1);

                // Do changes
                getState().setOriginalAt(row, col, false);
                b.setSpeciesAt(row, col, Species.AIR);
                b.setFrozenAt(row, col, false);

                // Ensure that even if something was activated mid-fall (i.e. mega gengar's
                // silliness)
                // then it will still be set to a proper resting state when finally cleared, so it
                // won't
                // interfere with other gravity effects and height measurements
                // This might not be needed, but it is good to maintain the state properly
                getState().setFallingPositionAt(row, col, 0);
            }

            if (!effect.shouldErase(row, col)) {
                scheduleEffect(new DelayThawEffect(Arrays.asList(row, col)), THAW_DELAY);
            }
        }
    }

    private void addProspectiveCombo(List<Integer> coords) {
        if (logFiner) {
            logFinerWithId("Recognized combo: %s", StringUtils.join(coords.toArray(new Integer[0])));
        }
        if (logFiner && isActiveCombo(coords)) {
            logFinerWithId("combo is already active: %s", StringUtils.join(coords.toArray(new Integer[0])));
        }
        if (coords.size() < 2 || isActiveCombo(coords)) {
            return;
        }
        Species effectSpecies = getEffectSpecies(coords);
        Effect effect = getEffectFor(effectSpecies);
        ActivateComboEffect activateEffect = new ActivateComboEffect(coords, effect);

        boolean horizontal = activateEffect.isHorizontal();
        Collection<ActivateComboEffect> toMerge = new HashSet<ActivateComboEffect>();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            Collection<ActivateComboEffect> claims = getClaimsFor(row, col);
            for (ActivateComboEffect claimEffect : claims) {
                if (claimEffect.isHorizontal() == horizontal) {
                    toMerge.add(claimEffect);
                }
            }
        }
        if (!toMerge.isEmpty()) {
            List<Integer> collectiveCoords = new ArrayList<Integer>(coords);
            for (ActivateComboEffect conflictingEffect : toMerge) {
                removeClaim(conflictingEffect);
                prospecticeCombosSet.remove(conflictingEffect);
                collectiveCoords.addAll(conflictingEffect.getCoords());
            }

            List<Integer> limits = getLimits(collectiveCoords);
            List<Integer> finalCoords = getComboForLimits(limits);
            activateEffect = new ActivateComboEffect(finalCoords, effect);
        }
        if (logFiner) {
            logFinerWithId("Claiming for combo: %s", activateEffect);
        }
        prospecticeCombosSet.add(activateEffect);
        addClaimFor(activateEffect);
    }

    public static List<Integer> getComboForLimits(List<Integer> limits) {
        List<Integer> ret = new ArrayList<Integer>();
        int rowDir = Integer.signum(limits.get(2) - limits.get(0));
        int colDir = Integer.signum(limits.get(3) - limits.get(1));
        if (rowDir == 0 && colDir == 0) {
            ret.addAll(Arrays.asList(limits.get(0), limits.get(1)));
        } else {
            int row = limits.get(0);
            int col = limits.get(1);
            while (row <= limits.get(2) && col <= limits.get(3)) {
                ret.add(row);
                ret.add(col);
                row += rowDir;
                col += colDir;
            }
        }
        return ret;
    }

    /**
     * Given a set of coordinates, returns a pair that denotes the lowest row,col and highest row,col
     * pair to bound the coordinates (inclusive).
     * 
     * @param coords
     * @return
     */
    public static List<Integer> getLimits(List<Integer> coords) {
        int minRow = coords.get(0);
        int minCol = coords.get(1);
        int maxRow = coords.get(0);
        int maxCol = coords.get(1);

        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            if (row < minRow) {
                minRow = row;
            }
            if (row > maxRow) {
                maxRow = row;
            }
            if (col < minCol) {
                minCol = col;
            }
            if (col > maxCol) {
                maxCol = col;
            }
        }

        return Arrays.asList(minRow, minCol, maxRow, maxCol);
    }

    public void removeCollisions(List<Integer> coords) {
        if (logFiner) {
            logFinerWithId("Removing collisions with: %s", StringUtils.join(coords.toArray(new Integer[0])));
        }
        Set<ActivateComboEffect> toRemove = new HashSet<ActivateComboEffect>();
        for (ActivateComboEffect combo : prospecticeCombosSet) {
            boolean shouldRemove = false;
            for (int i = 0; !shouldRemove && i * 2 + 1 < coords.size(); i++) {
                int row = coords.get(i * 2);
                int col = coords.get(i * 2 + 1);
                shouldRemove |= combo.containsCoords(row, col);
            }
            if (shouldRemove) {
                toRemove.add(combo);
            }
        }
        for (ActivateComboEffect comboEffect : toRemove) {
            prospecticeCombosSet.remove(comboEffect);
            removeClaim(comboEffect);
        }
    }

    public void scheduleEffect(ComboEffect effect, int delay) {
        if (logFiner) {
            logFinerWithId("Scheduling combo after %s frames: %s", delay, effect);
        }
        effect.init(this);
        addActiveFor(effect);
        int timeStamp = curTimeStamp + delay;
        if (!simulationEffects.containsKey(timeStamp)) {
            simulationEffects.put(timeStamp, new HashSet<ComboEffect>());
            simulationEffectTimes.offer(timeStamp);
        }
        simulationEffects.get(timeStamp).add(effect);
    }

    private Collection<ComboEffect> popCurrentEffects() {
        Collection<ComboEffect> ret = simulationEffects.remove(curTimeStamp);
        if (ret == null) {
            ret = Collections.emptyList();
        }
        simulationEffectTimes.remove(curTimeStamp);
        return ret;
    }

    public SimulationState getState() {
        return state;
    }

    public EraseComboEffect getWoodShatterEffect(EraseComboEffect comboEffect) {
        Set<List<Integer>> woodCoords = new HashSet<List<Integer>>();
        Board b = getState().getBoard();
        int[] nearby = new int[] { 0, -1, 0, 1, 1, 0, -1, 0 };
        List<Integer> coords = comboEffect.getCoords();

        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int myrow = coords.get(i * 2);
            int mycol = coords.get(i * 2 + 1);
            if (comboEffect.shouldErase(myrow, mycol)) {
                for (int k = 0; k * 2 + 1 < nearby.length; k++) {
                    int row = myrow + nearby[k * 2];
                    int col = mycol + nearby[k * 2 + 1];
                    if (!isClaimed(row, col) && !isFalling(row, col) && !isActive(row, col)) {
                        Species neighbour = b.getSpeciesAt(row, col);
                        if (getEffectFor(neighbour).equals(Effect.WOOD)) {
                            woodCoords.add(Arrays.asList(row, col));
                        }
                    }
                }
            }
        }
        EraseComboEffect ret = null;
        if (!woodCoords.isEmpty()) {
            List<Integer> retCoords = new ArrayList<Integer>(woodCoords.size() * 2);
            for (List<Integer> coord : woodCoords) {
                retCoords.addAll(coord);
            }
            ret = new EraseComboEffect(retCoords);
            ret.setForceErase(true);
        }
        return ret;
    }

    public NumberSpan getScoreFor(ActivateComboEffect comboEffect) {
        int numCombos = comboEffect.getNumCombosOnActivate();
        return getScoreFor(comboEffect, numCombos);
    }

    public NumberSpan getScoreFor(ActivateComboEffect comboEffect, int numCombos) {
        double comboMultiplier = getComboMultiplier(numCombos + 1);
        Species effectSpecies = getEffectSpecies(comboEffect.getCoords());
        int basicScore = getBasicScoreFor(effectSpecies);
        double typeMod = getTypeModifier(effectSpecies);
        double numBlocksModifier = getNumBlocksMultiplier(comboEffect.getNumBlocks());
        Effect effect = getEffectFor(effectSpecies);
        NumberSpan effectSpecial = effect.getScoreMultiplier(comboEffect, this);
        // gets the score without an effect affecting it
        double preEffectScore = basicScore * typeMod * comboMultiplier * numBlocksModifier;
        // gets the real integer floored minimum
        int finalMin = (int) (effectSpecial.getMinimum() * preEffectScore);
        // gets the real integer floored maximum
        int finalMax = (int) (effectSpecial.getMaximum() * preEffectScore);
        // gets the effect's ratio of average to minimum, which will be preserved.
        double ratio = effectSpecial.getAverage() / effectSpecial.getMinimum();
        // Gets the new average by multiplying that ratio by the integer minimum
        // This is the real average because all we wanted was the distribution
        // but now we just want the REAL distribution of values for actual score
        double average = finalMin * ratio;
        // NumberSpan finalScore = effectSpecial.multiplyBy(preEffectScore);
        NumberSpan finalScore = new NumberSpan(finalMin, finalMax, average, 1);
        if (logFiner) {
            logFinerWithId("Calculated score as %s for combo %s", finalScore, comboEffect);
        }
        if (getState().getCore().isAttackPowerUp()) {
            finalScore = finalScore.multiplyBy(2.0);
        }
        return effect.modifyScoreRange(comboEffect, this, finalScore);
    }

    /**
     * Returns the chain multiplier, given the number of the combo for this match.<br>
     * Chain modifiers:<br>
     * 1: x1 <br>
     * 2-4: x1.1<br>
     * 5-9: x1.15<br>
     * 10-24: x1.2<br>
     * 25-49: x1.3<br>
     * 50-74: x1.4<br>
     * 75-99: x1.5<br>
     * 100-199: x2<br>
     * 200+: x2.5<br>
     * 
     * @param combos
     *           The number of consecutive combos in a chain.
     * @return 1 for any value of combos &lt;= 1, otherwise see above reference.
     */
    public static double getComboMultiplier(int combos) {
        double comboMultiplier = COMBO_MULTIPLIER[0];
        int i = 0;
        while (i + 1 < COMBO_THRESHOLD.length && COMBO_THRESHOLD[i + 1] <= combos) {
            comboMultiplier = COMBO_MULTIPLIER[i + 1];
            i++;
        }
        return comboMultiplier;
    }

    /**
     * @param effectSpecies
     * @return
     */
    public int getBasicScoreFor(Species effectSpecies) {
        int level = getState().getCore().getLevel(effectSpecies);
        // gets the basic block score for this species in this stage
        return effectSpecies.getAttack(level);
    }

    /**
     * @param effectSpecies
     * @return
     */
    public double getTypeModifier(Species effectSpecies) {
        PkmType stageType = getState().getCore().getStage().getType();
        double typeMod = PkmType.getMultiplier(getState().getSpeciesType(effectSpecies), stageType);
        return typeMod;
    }

    private static final double[] NUM_BLOCK_MULTIPLIER = new double[] { 0.3, 0.6, 1.0, 1.5, 2.0, 3.0 };

    /**
     * Returns the number of blocks multiplier, given how many blocks were in the combo. 1 = 0.3, 2 =
     * 0.6, 3 = 1.0; 4 = 1.5; 5 = 2.0; 6 = 3.0
     * 
     * @param numBlocks
     * @return A double, representing a modifier for the combo's base score
     */
    private double getNumBlocksMultiplier(int numBlocks) {
        int n = numBlocks;
        if (n < 1) {
            if (logFiner) {
                logFinerWithId("numBlocks was below 1", n);
            }
            n = 1;
        } else if (n > 6) {
            if (logFiner) {
                logFinerWithId("numBlocks was above 6", n);
            }
            n = 6;
        }
        return NUM_BLOCK_MULTIPLIER[n - 1];
    }

    public void addScore(NumberSpan score) {
        if (logFiner) {
            logFinerWithId("Adding score: %s", score);
        }
        getState().addScore(score);
    }

    public void handleMainComboResult(ActivateComboEffect comboEffect, Effect effect) {
        NumberSpan scoreToAdd = getScoreFor(comboEffect);
        if (logFiner) {
            logFinerWithId("Adding main score of %s for combo %s", scoreToAdd, comboEffect);
        }
        removeActive(comboEffect);

        handleMegaIncreases(comboEffect);
        addScore(scoreToAdd);

        List<Integer> coords = comboEffect.getCoords();
        EraseComboEffect erasureEffect = new EraseComboEffect(coords);
        erasureEffect.setForceErase(comboEffect instanceof ActivateMegaComboEffect);
        scheduleEffect(erasureEffect, effect.getErasureDelay());
        erasureEffect.inheritPersistenceFrom(comboEffect);

        EraseComboEffect woodShatter = getWoodShatterEffect(erasureEffect);
        if (woodShatter != null) {
            scheduleEffect(woodShatter, Effect.WOOD.getErasureDelay());
        }

        if (logFiner) {
            logFinerWithId("Number of total blocks cleared is now: %s", getState().getBlocksCleared());
        }
    }

    /**
     * @param coords
     */
    protected void handleMegaIncreases(ActivateComboEffect comboEffect) {
        List<Integer> coords = comboEffect.getCoords();
        if (getState().getCore().isMegaAllowed()) {
            Species effectSpecies = getEffectSpecies(coords);
            Species megaSlot = getState().getCore().getMegaSlot();
            if (megaSlot != null && megaSlot.equals(effectSpecies)) {
                int megaIncrease = comboEffect.getNumMegaBoost();
                getState().increaseMegaProgress(megaIncrease);
                if (logFiner) {
                    logFinerWithId("Mega progress is now %s of %s", getState().getMegaProgress(),
                            getState().getCore().getMegaThreshold());
                }
            }
        }
    }

    public Species getEffectSpecies(List<Integer> coords) {
        Board b = getState().getBoard();
        Species s = Species.AIR;
        for (int i = 0; !getEffectFor(s).isPickable() && i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            s = b.getSpeciesAt(row, col);
        }
        return s;
    }

    /**
     * Returns all matches for the given parameters, as an ordered list of row and column pairs in
     * the grid of [1, NUM_ROWS] x [1, NUM_COLS].
     * 
     * @param limit
     *           The maximum number of result coordinates
     * @param includeActive
     *           Should this include active tiles
     * @param function
     *           The check for adding a result. (row, column, species) -&gt; boolean value, if true
     *           then include. reject otherwise.
     * @return
     */
    public List<Integer> findMatches(int limit, boolean includeActive,
            TriFunction<Integer, Integer, Species, Boolean> function) {
        List<Integer> match = new ArrayList<Integer>();
        for (int row = 1; match.size() / 2 < limit && row <= Board.NUM_ROWS; row++) {
            for (int col = 1; match.size() / 2 < limit && col <= Board.NUM_COLS; col++) {
                if (!includeActive && isActive(row, col)) {
                    continue;
                }
                Species thisSpecies = getState().getBoard().getSpeciesAt(row, col);
                if (function.apply(row, col, thisSpecies)) {
                    match.addAll(Arrays.asList(row, col));
                }
            }
        }
        return match;
    }

    public List<Integer> filterPlanBy(List<Integer> plan, boolean includeActive,
            TriFunction<Integer, Integer, Species, Boolean> function) {
        if (plan == null) {
            return null;
        }
        List<Integer> ret = new ArrayList<Integer>();
        for (int i = 0; i * 2 + 1 < plan.size(); i++) {
            int row = plan.get(i * 2);
            int col = plan.get(i * 2 + 1);
            if (!includeActive && isActive(row, col)) {
                continue;
            }
            Species thisSpecies = getState().getBoard().getSpeciesAt(row, col);
            if (function.apply(row, col, thisSpecies)) {
                ret.add(row);
                ret.add(col);
            }
        }
        return ret;
    }

    public Effect getEffectFor(Species s) {
        Effect effect = getState().getCore().getEffectFor(s);
        Effect megaEffect = s.getMegaEffect();
        if (s.getMegaName() != null // Has a mega name
                && !megaEffect.equals(Effect.NONE) // Mega effect is well defined
                && getState().isMegaActive() // Mega is actually active right now
                && getState().getCore().getMegaSlot().equals(s)) { // and the mega slot IS this species.
            effect = megaEffect;
        }
        if (logFiner) {
            logFinerWithId("Effect Query for Species %s, Returned %s", s, effect);
        }
        return effect;
    }

    public void eraseBonusIn(List<Integer> toErase, int erasureDelay) {
        eraseBonusIn(toErase, erasureDelay, true);
    }

    public void eraseBonusIn(List<Integer> toErase, int erasureDelay, boolean forceErase) {
        if (logFiner) {
            logFinerWithId("Scheduling erasure after %s frames for %s", erasureDelay,
                    StringUtils.join(toErase.toArray(new Integer[0])));
        }
        for (int i = 0; i * 2 + 1 < toErase.size(); i++) {
            int row = toErase.get(i * 2);
            int col = toErase.get(i * 2 + 1);
            removeClaimsFor(row, col);
        }
        boardChanged = true;
        EraseComboEffect eraseBonus = new EraseComboEffect(toErase);
        eraseBonus.setForceErase(forceErase);
        scheduleEffect(eraseBonus, erasureDelay);
    }

    public void unfreezeAt(List<Integer> coords) {
        if (coords == null || coords.size() < 2) {
            return;
        }
        Board b = getState().getBoard();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            if (b.isFrozenAt(row, col)) {
                getState().addDisruptionCleared(1);
            }
            b.setFrozenAt(row, col, false);
            removeClaimsFor(row, col);
            scheduleEffect(new DelayThawEffect(Arrays.asList(row, col)),
                    Effect.getDefaultErasureDelay() + THAW_DELAY);
        }
    }

    public void uncloudAt(List<Integer> coords) {
        if (coords == null || coords.size() < 2) {
            return;
        }
        Board b = getState().getBoard();
        for (int i = 0; i * 2 + 1 < coords.size(); i++) {
            int row = coords.get(i * 2);
            int col = coords.get(i * 2 + 1);
            if (b.isCloudedAt(row, col)) {
                getState().addDisruptionCleared(1);
            }
            b.setClouded(row, col, false);
        }
    }

    /**
     * 
     */
    public void setIsRandom() {
        getState().setIsRandom();
    }

    public boolean canStatusActivate() {
        return getState().getBoard().getStatus().isNone();
    }
}