package org.drooms.impl; import org.drooms.api.*; import org.drooms.impl.logic.CommandDistributor; import org.drooms.impl.logic.commands.*; import org.drooms.impl.util.GameProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.security.SecureRandom; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** * Provide a common ground for various types of games. We introduce a couple of * concepts and let the implementing classes specify the rules around those * concepts. The following concepts will be shared by all games extending this * class: * * <ul> * <li>Each player gets one worm. Properties of these worms come from the game config and will be explained later. List * of players comes from the player config.</li> * <li>When a worm collides with something, it is terminated. Collisions are determined by classes extending this one.</li> * <li>When a worm's past couple decisions were all STAY (see {@link Action}), the worm may be terminated. This is * controlled by the classes extending this one.</li> * <li>When a turn ends, worms may be rewarded for surviving. How and when, that depends on the classes extending this * one.</li> * <li>Terminated worms will disappear from the playground in the next turn.</li> * <li>In each turn, a collectible item of a certain value may appear in the playground. These collectibles will * disappear after a certain amount of turns. Worms who collect them in the meantime will be rewarded. How often, how * valuable and how persistent the collectibles are, that depends on the classes extending this one.</li> * <li>Upon collecting an item, the worm's length will increase by 1.</li> * <li>Game ends either when there are between 0 and 1 worms standing or when a maximum number of turns is reached.</li> * <li>At the end of the game, a player whose worm has the most points is declared the winner.</li> * </ul> * * <p> * Some of the decisions can be made by classes extending this one. These are clearly described above. This class * depends on properties as defined in {@link GameProperties}. * </p> * */ public abstract class GameController implements Game { private final AtomicBoolean played = new AtomicBoolean(false); private static final Logger LOGGER = LoggerFactory.getLogger(GameController.class); private GameProgressListener reporter; protected static final SecureRandom RANDOM = new SecureRandom(); private final Map<Player, Integer> playerPoints = new HashMap<>(); private final Map<Player, Integer> lengths = new HashMap<>(); private final Map<Player, PlayerPosition> positions = new HashMap<>(); private final Map<Node, Collectible> collectiblesByNode = new HashMap<>(); private final Map<Player, SortedMap<Integer, Action>> decisionRecord = new HashMap<>(); private GameProperties gameConfig; private final Collection<GameProgressListener> listeners = new HashSet<>(); private void addCollectible(final Collectible c) { this.collectiblesByNode.put(c.getAt(), c); } private void addDecision(final Player p, final Action m, final int turnNumber) { if (!this.decisionRecord.containsKey(p)) { this.decisionRecord.put(p, new TreeMap<>()); } this.decisionRecord.get(p).put(turnNumber, m); } @Override public boolean addListener(final GameProgressListener listener) { return this.listeners.add(listener); } protected Collectible getCollectible(final Node n) { return this.collectiblesByNode.get(n); } protected List<Action> getDecisionRecord(final Player p) { if (!this.decisionRecord.containsKey(p)) { return Collections.emptyList(); } else { return Collections.unmodifiableList(this.decisionRecord.get(p).values().stream().collect( Collectors.toList())); } } protected int getPlayerLength(final Player p) { if (!this.lengths.containsKey(p)) { throw new IllegalStateException("Player doesn't have any length assigned: " + p); } return this.lengths.get(p); } protected PlayerPosition getPlayerPosition(final Player p) { if (!this.positions.containsKey(p)) { throw new IllegalStateException("Player doesn't have any position assigned: " + p); } return this.positions.get(p); } @Override public GameProgressListener getReport() { return this.reporter; } /** * Decide which {@link Collectible}s should be considered collected by which * worms. * * @param players * Players still in the game. * @return Which collectible is collected by which player. */ protected abstract Map<Collectible, Player> performCollectibleCollection(final Collection<Player> players); /** * Decide which new {@link Collectible}s should be distributed. * * @param gameConfig * Game config with information about the {@link Collectible} types. * @param playground * Playground on which to distribute. * @param players * Players still in the game. * @param currentTurnNumber * Current turn number. * @return Which collectibles should be put where. */ protected abstract Collection<Collectible> performCollectibleDistribution(final GameProperties gameConfig, final Playground playground, final Collection<Player> players, final int currentTurnNumber); /** * Perform collision detection for worms. * * @param playground * Playground on which to detect collisions. * @param currentPlayers * Players still in the game. * @return Which players should be considered crashed. */ protected abstract Set<Player> performCollisionDetection(final Playground playground, final Collection<Player> currentPlayers); /** * Decide which worms should be considered inactive. * * @param currentPlayers * Players still in the game. * @param currentTurnNumber * Current turn number. * @param allowedInactiveTurns * How many turns a player can not move before considered * inactive. * @return Which players should be considered inactive. */ protected abstract Set<Player> performInactivityDetection(final Collection<Player> currentPlayers, final int currentTurnNumber, final int allowedInactiveTurns); /** * Decide where the worm should be after it has performed a particular action. * * @param currentPosition Current player position. * @param decision * The action to perform. * @return New positions for the worm. */ protected abstract PlayerPosition performPlayerAction(final PlayerPosition currentPosition, final Action decision); /** * Decide which players should be rewarded for survival in this round. * * @param allPlayers * All the players that ever were in the game. * @param survivingPlayers * Players that remain in the game. * @param removedInThisRound * Number of players removed in this round. * @param rewardAmount * How many points to award. * @return How much each player should be rewarded. Players not mentioned * are not rewarded. */ protected abstract Map<Player, Integer> performSurvivalRewarding(Collection<Player> allPlayers, Collection<Player> survivingPlayers, int removedInThisRound, int rewardAmount); private Map<Player, Action> playTurn(final Playground playground, final Collection<Player> players, final CommandDistributor playerControl, final Map<Player, Action> previousDecisions, final int turnNumber, final int allowedInactiveTurns, final int wormSurvivalBonus) { GameController.LOGGER.info("--- Starting turn no. {}.", turnNumber); final int preRemoval = playerControl.getPlayers().size(); // remove inactive worms this.performInactivityDetection(playerControl.getPlayers(), turnNumber, allowedInactiveTurns).forEach(player -> { GameController.LOGGER.info("Player {} will be removed for inactivity.", player.getName()); playerControl.distributeCommand(new DeactivatePlayerCommand(player)); }); // move the worms playerControl.getPlayers().forEach(p -> { final Action m = previousDecisions.getOrDefault(p, Action.NOTHING); if (turnNumber > GameProperties.FIRST_TURN_NUMBER) { this.addDecision(p, m, turnNumber - 1); // store decision from previous turn } final PlayerPosition newPosition = this.performPlayerAction(this.getPlayerPosition(p), m); this.setPlayerPosition(newPosition); playerControl.distributeCommand(new PlayerActionCommand(m, newPosition)); }); // resolve worms colliding this.performCollisionDetection(playground, playerControl.getPlayers()).stream().forEach(player -> { playerControl.distributeCommand(new CrashPlayerCommand(player)); }); final Collection<Player> survivingPlayers = playerControl.getPlayers(); final int postRemoval = survivingPlayers.size(); this.performSurvivalRewarding(players, survivingPlayers, preRemoval - postRemoval, wormSurvivalBonus) .forEach((p, amount) -> { this.reward(p, amount); playerControl.distributeCommand(new RewardSurvivalCommand(p, amount)); }); // expire uncollected collectibles this.collectiblesByNode.values().stream().filter(c -> c.expires() && turnNumber >= c.expiresInTurn()) .forEach(c -> { playerControl.distributeCommand(new RemoveCollectibleCommand(c)); this.removeCollectible(c); }); // add points for collected collectibles this.performCollectibleCollection(survivingPlayers).forEach((c, p) -> { this.reward(p, c.getPoints()); playerControl.distributeCommand(new CollectCollectibleCommand(c, p)); this.removeCollectible(c); this.setPlayerLength(p, this.getPlayerLength(p) + 1); }); if (postRemoval < 2) { // end turn prematurely since not enough players survived return Collections.emptyMap(); } // distribute new collectibles this.performCollectibleDistribution(this.gameConfig, playground, survivingPlayers, turnNumber).stream() .forEach(c -> { this.addCollectible(c); playerControl.distributeCommand(new AddCollectibleCommand(c)); }); // make the move decision return playerControl.execute(); } @Override public Map<Player, Integer> play(final Playground playground, final Collection<Player> players, final File reportFolder) { if (this.gameConfig == null) { throw new IllegalStateException("Game context had not been set!"); } // make sure a game isn't played more than once if (this.played.get()) { throw new IllegalStateException("This game had already been played."); } this.played.set(true); // prepare the playground final int wormLength = this.gameConfig.getStartingWormLength(); final int allowedInactiveTurns = this.gameConfig.getMaximumInactiveTurns(); final int allowedTurns = this.gameConfig.getMaximumTurns(); final int wormSurvivalBonus = this.gameConfig.getDeadWormBonus(); final int wormTimeout = this.gameConfig.getStrategyTimeoutInSeconds(); // prepare players and their starting positions final List<Node> startingPositions = playground.getStartingPositions(); final int playersSupported = startingPositions.size(); final int playersAvailable = players.size(); if (playersSupported < playersAvailable) { throw new IllegalArgumentException("The playground doesn't support " + playersAvailable + " players, only " + playersSupported + "! "); } players.forEach(player -> { final int playerPosition = playerPoints.size(); this.setPlayerPosition(PlayerPosition.build(playground, player, startingPositions.get(playerPosition))); this.setPlayerLength(player, wormLength); playerPoints.put(player, 0); GameController.LOGGER.info("Player {} assigned position {}.", player.getName(), playerPosition); }); // prepare situation this.reporter = new XmlProgressListener(playground, players, this.gameConfig); final CommandDistributor playerControl = new CommandDistributor(playground, players, this.reporter, this.gameConfig, reportFolder, wormTimeout); this.listeners.forEach(listener -> playerControl.addListener(listener)); Map<Player, Action> decisions = Collections.emptyMap(); // start the game int turnCount = 0; do { final int turnNumber = turnCount + GameProperties.FIRST_TURN_NUMBER; decisions = this.playTurn(playground, players, playerControl, decisions, turnNumber, allowedInactiveTurns, wormSurvivalBonus); turnCount++; if (turnCount == allowedTurns) { GameController.LOGGER.info("Reached a pre-defined limit of {} turns. Terminating game.", allowedTurns); break; } else if (playerControl.getPlayers().size() < 2) { GameController.LOGGER.info("There are no more players. Terminating game."); break; } } while (true); playerControl.terminate(); // clean up all the sessions // output player status GameController.LOGGER.info("--- Game over."); this.playerPoints.forEach((key, value) -> GameController.LOGGER.info("Player {} earned {} points.", key.getName(), value)); return Collections.unmodifiableMap(this.playerPoints); } private void removeCollectible(final Collectible c) { this.collectiblesByNode.remove(c.getAt()); } @Override public boolean removeListener(final GameProgressListener listener) { return this.listeners.remove(listener); } private void reward(final Player p, final int points) { this.playerPoints.put(p, this.playerPoints.get(p) + points); } /** * */ @Override public void setContext(final InputStream context) { try { this.gameConfig = GameProperties.read(context); } catch (final IOException ex) { throw new IllegalArgumentException("Failed reading game properties."); } } private void setPlayerLength(final Player p, final int length) { this.lengths.put(p, length); } private void setPlayerPosition(final PlayerPosition position) { this.positions.put(position.getPlayer(), position); } /** * Build the playground from an input stream. Each line in that stream * represents one row on the playground. Each '#' in that line represents a * wall node, ' ' represents a node where the worm can move, '@' represents * a possible starting position for a worm. (Starting positions also can be * moved into.) Any other sign, other than a line break, will result in an * exception. * * @param name * Name for the new playground. * @param source * Stream in question. * @return Playground constructed from that stream. */ @Override public Playground buildPlayground(final String name, final InputStream source) { try (final BufferedReader reader = new BufferedReader(new InputStreamReader(source))) { final List<String> lines = reader.lines().collect(Collectors.toList()); Collections.reverse(lines); // this way, 0,0 is bottom left return new DefaultPlayground(name, lines); } catch (final Exception ex) { throw new IllegalStateException("Cannot read playground " + name, ex); } } }