package server.automaton; import server.*; import server.cards.*; import server.*; import server.cards.*; import server.utils.LoadingCardsException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; /** * Central game controller for MESOS. * * Owns the full game lifecycle: * * SETUP → newGame() * TOTEM_PLACEMENT → placeTotem() — one call per player, in TurnTile order * ACTION_RESOLUTION → resolveCardAction() — one call per pending action * EXTRA_DRAW → extraDrawAction() / skipExtraDraw() [only if a player owns the building] * EVENT_RESOLUTION → handled automatically at end of round * GAME_OVER → endGame() * * All public methods that advance state return a boolean: * true = action accepted, state may have changed * false = action rejected, state unchanged (caller should log / notify client) * * NOTE: GameState must have EXTRA_DRAW added to the enum for this class to compile: * UNKNOWN, SETUP, TOTEM_PLACEMENT, ACTION_RESOLUTION, EXTRA_DRAW, EVENT_RESOLUTION, GAME_OVER * * NOTE: Tribe.endPoints() already computes end-game building bonuses internally via * buildingAbilitiesEndPoints(). Do NOT also call BuildingManager.endgameForSix() etc. * in endGame() — that would count those bonuses twice. */ public class Game { private static final Logger logger = LogManager.getLogger(Game.class); // ------------------------------------------------------------------------- // Constants // ------------------------------------------------------------------------- private static final int TOTAL_ROUNDS = 10; // ------------------------------------------------------------------------- // Core state // ------------------------------------------------------------------------- private final List players; private GameBoard gameBoard; private int round; private GameState state; // ------------------------------------------------------------------------- // Phase tracking // ------------------------------------------------------------------------- /** How many players have placed their totem this round. */ private int totemPlacedCount; /** Index of the offering tile (central row) currently being resolved (left to right). */ private int currentOfferingTileIndex; /** Actions the current player still has to resolve. */ private List pendingActions; /** * Queue of players who own the EXTRA_DRAW building and still need to * make their extra-draw decision this round. Ordered by turn order. */ //TODO to check it should be a single player private final List extraDrawQueue; // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- public Game(List players) { this.players = new ArrayList<>(players); this.round = 0; this.state = GameState.SETUP; this.pendingActions = new ArrayList<>(); this.extraDrawQueue = new ArrayList<>(); } // ========================================================================= // SETUP // ========================================================================= /** * Initialises a new game: loads the deck, builds the board, deals initial * food, and randomises the first turn order. * * Initial food (rulebook setup step 10): * Slot 1 -> 2 food, slots 2-3 -> 3 food, slots 4-5 -> 4 food. */ public void newGame(InputStream csvStream) { try { logger.info("STARTING NEW GAME"); CardDeck deck = new CardDeck(); deck.setForNPlayer(csvStream, players.size()); gameBoard = new GameBoard(Era.I, deck, players.size()); gameBoard.initOfferingTiles(players.size()); gameBoard.setupInitialRows(players.size()); gameBoard.getTurnTile().setInitialOrder(players, true); dealInitialFood(); round = 1; totemPlacedCount = 0; state = GameState.TOTEM_PLACEMENT; logger.info("Game started! Round 1."); logTurnOrder(); } catch (LoadingCardsException e) { System.err.println("Fatal: could not load cards — " + e.getMessage()); state = GameState.GAME_OVER; } } private void dealInitialFood() { Player[] order = gameBoard.getTurnTile().getPositions(); for (int i = 0; i < order.length; i++) { int food = (i == 0) ? 2 : (i <= 2) ? 3 : 4; order[i].addFood(food); logger.info(" " + order[i].getNickname() + " starts with " + food + " food."); } } // ========================================================================= // PHASE 1 — TOTEM PLACEMENT // ========================================================================= /** * Places the player's totem on an offering tile. * * Players must place in the order defined by the TurnTile (top to bottom). * If the wrong player calls this, it is rejected. * * @param player the player attempting to place * @param tileIndex 0-based index into the offering tile list * @return true if placement was accepted */ public boolean placeTotem(Player player, int tileIndex) { if (state != GameState.TOTEM_PLACEMENT) { logger.info("Error: not the totem-placement phase."); return false; } // Enforce turn order Player expected = gameBoard.getTurnTile().getPositions()[totemPlacedCount]; if (expected != player) { logger.info("Error: it is " + expected.getNickname() + "'s turn to place, not " + player.getNickname() + "."); return false; } List tiles = gameBoard.getOfferingTiles(); if (tileIndex < 0 || tileIndex >= tiles.size()) { logger.info("Error: tile index " + tileIndex + " out of range."); return false; } OfferingTile chosen = tiles.get(tileIndex); if (!gameBoard.placeTotem(player, chosen, gameBoard.getTurnTile())) { logger.info("Tile " + chosen.getLetter() + " is already occupied."); return false; } totemPlacedCount++; logger.info("player {} TOTEM PLACED ON TILE {}", player.getNickname() , chosen.getLetter()); // + " (" + totemPlacedCount + "/" + players.size() + ")."); if (totemPlacedCount == players.size()) { logger.info("START ACTION RESOLUTION"); startActionResolution(); } return true; } // ========================================================================= // PHASE 2 — ACTION RESOLUTION // ========================================================================= private void startActionResolution() { state = GameState.ACTION_RESOLUTION; currentOfferingTileIndex = 0; logger.info("All totems placed — resolving actions left to right."); loadNextPlayerActions(); } /** * Scans offering tiles left to right for the next occupied one, loads that * player's actions, and either resolves them automatically (food tile) or * waits for player input (card tiles). * * Calls onAllActionsResolved() when every tile has been processed. */ private void loadNextPlayerActions() { List tiles = gameBoard.getOfferingTiles(); while (currentOfferingTileIndex < tiles.size()) { OfferingTile tile = tiles.get(currentOfferingTileIndex); if (tile.isEmpty()) { currentOfferingTileIndex++; continue; } Player player = tile.getOccupant(); pendingActions = new ArrayList<>(tile.getActions()); logger.info(" PLAYER {} PENDING_ACTIONS {} ", player.getNickname() , pendingActions ); // --- Food tile: resolve entirely without player input --- if (pendingActions.get(0) == Symbol.FOOD) { int food = pendingActions.size(); player.addFood(food); BuildingManager.bonusEndTurn(player, food); // BONUS_FOOD_ENDTURN building effect logger.info(player.getNickname() + " receives " + food + " food from tile " + tile.getLetter() + "."); pendingActions.clear(); finishPlayerTurn(player); currentOfferingTileIndex++; continue; } // --- Card tile: strip impossible actions first --- filterImpossibleActions(player); if (pendingActions.isEmpty()) { // All actions were impossible (both rows empty) finishPlayerTurn(player); currentOfferingTileIndex++; continue; } // Wait for the player to call resolveCardAction() logger.info("player {} pending ACTIONS {}" , player.getNickname() , pendingActions); // logger.info("It is " + player.getNickname() + "'s turn — actions: " + pendingActions); return; } // Every tile has been processed onAllActionsResolved(); } /** * Strips actions that cannot be executed because the required row is empty. * Pure filter — no side effects beyond mutating pendingActions. */ private void filterImpossibleActions(Player player) { Card ctop = gameBoard.getTopRow().stream().filter(s -> s instanceof CharacterCard || s instanceof BuildingCard).findFirst().orElse(null); Card cdown = gameBoard.getBottomRow().stream().filter(s -> s instanceof CharacterCard || s instanceof BuildingCard).findFirst().orElse(null); boolean topEmpty = ctop == null; boolean bottomEmpty = cdown == null; if (topEmpty) { long removed = pendingActions.stream().filter(s -> s == Symbol.UP).count(); pendingActions.removeIf(s -> s == Symbol.UP); if (removed > 0) logger.info("{} LOSES ACTION UP" ,player.getNickname(), removed); } if (bottomEmpty) { long removed = pendingActions.stream().filter(s -> s == Symbol.DOWN).count(); pendingActions.removeIf(s -> s == Symbol.DOWN); if (removed > 0) logger.info("{} LOSES ACTION DOWN" ,player.getNickname(), removed); } } /** * Resolves one card-pick action for the current player. * Works for both CharacterCards and BuildingCards. * * For BuildingCards: builder discount is applied and the player must have * enough food. If not, the card is put back and the call is rejected — the * player must choose a different card. * * @param player the acting player (must match the current tile occupant) * @param fromTop true = pick from the top row; false = from the bottom row * @param cardId the ID of the card to take * @return true if the action was accepted and consumed */ public ActionResult resolveCardAction(Player player, boolean fromTop, int cardId) { if (state != GameState.ACTION_RESOLUTION) { logger.info("Error: not in ACTION_RESOLUTION phase."); return ActionResult.failure("Error: not in ACTION_RESOLUTION phase."); } OfferingTile currentTile = gameBoard.getOfferingTiles().get(currentOfferingTileIndex); if (currentTile.getOccupant() != player) { logger.info("Error: it is not " + player.getNickname() + "'s turn to act."); return ActionResult.failure("Error: it is not " + player.getNickname() + "'s turn to act."); } Symbol required = fromTop ? Symbol.UP : Symbol.DOWN; if (!pendingActions.contains(required)) { logger.info("Error: no " + required + " action available for " + player.getNickname() + "."); return ActionResult.failure("Error: no " + required + " action available for " + player.getNickname() + "."); } // Find the card in the correct row Card card = fromTop ? gameBoard.takeFromTopRow(cardId) : gameBoard.takeFromBottomRow(cardId); if (card == null) { logger.info("Error: card " + cardId + " not found in the " + (fromTop ? "top" : "bottom") + " row."); return ActionResult.failure("Error: card " + cardId + " not found in the " + (fromTop ? "top" : "bottom") + " row."); } // Event cards can never be taken by players if (card instanceof EventCard) { putCardBack(card, fromTop); logger.info("Error: Event cards cannot be taken."); return ActionResult.failure("Error: Event cards cannot be taken."); } // Resolve the card if (card instanceof BuildingCard) { ActionResult result =resolveBuildingCard(player, (BuildingCard) card); if (!result.isSuccess()) { putCardBack(card, fromTop); return result; } } else { resolveCharacterCard(player, (CharacterCard) card); } // Consume the action pendingActions.remove(required); logger.info("player {} TOOK CARD {} --> PEDNING ACTIONS {}",player.getNickname(), cardId, pendingActions); // A row may have become empty after this draw — re-check impossible actions if (!pendingActions.isEmpty()) { filterImpossibleActions(player); } // If no more actions remain, finish this player's turn if (pendingActions.isEmpty()) { finishPlayerTurn(player); currentOfferingTileIndex++; loadNextPlayerActions(); } return ActionResult.ok(); } /** * Handles building card acquisition. * Applies builder discount, checks affordability, deducts food, and * registers the building with BuildingManager. * * @return true if the acquisition succeeded */ private ActionResult resolveBuildingCard(Player player, BuildingCard building) { int discount = player.getPlayerTribe().buildersDiscount(); int actualCost = Math.max(0, building.getCost() - discount); if (player.getFoodTokens() < actualCost) { logger.info(player.getNickname() + " cannot afford building " + building.getCardId() + " (cost after discount: " + actualCost + ", has: " + player.getFoodTokens() + " food)."); return ActionResult.failure(player.getNickname() + " cannot afford building " + building.getCardId() + " (cost after discount: " + actualCost + ", has: " + player.getFoodTokens() + " food)."); } player.removeFood(actualCost); player.getPlayerTribe().addBuilding(building); gameBoard.getBuildingManager().addActiveBuilding(building, player); logger.info(player.getNickname() + " bought building " + building.getCardId() + " for " + actualCost + " food."); return ActionResult.ok(); } /** * Handles character card acquisition and triggers all immediate effects: * * Hunter with leg icon (iconValue > 0): * Take 1 food per Hunter currently in tribe (including this one). * * FOOD_FOR_SIX building: * Take 6 food if this draw completes a full set of all 6 character types. * * FOOD_PER_INVENTORS building: * Take 3 food if this Inventor forms a matching pair. * * The card is added to the tribe BEFORE effects fire so all counts include it. */ private void resolveCharacterCard(Player player, CharacterCard character) { player.getPlayerTribe().addCharacter(character); // Hunter with leg icon: immediate food reward if (character.getCharacterType() == CharacterType.HUNTER && character.getIconValue() > 0) { int food = player.getPlayerTribe().huntersNumber() * character.getIconValue(); player.addFood(food); logger.info(player.getNickname() + "'s hunter (leg icon) grants +" + food + " food."); } // Building-triggered effects on character draw gameBoard.getBuildingManager().foodForSix(player, character); if (character.getCharacterType() == CharacterType.INVENTOR) { gameBoard.getBuildingManager().foodPerInventors(player, character); } logger.info(player.getNickname() + " drew " + character.getCharacterType() + " (id " + character.getCardId() + ")."); } /** * Marks the current player as done with their tile actions. * Calls TurnTile.returnTotem(), which places them in the next available * slot and immediately applies the position food reward or penalty. */ private void finishPlayerTurn(Player player) { int slot = gameBoard.getTurnTile().returnTotem(player); OfferingTile off = gameBoard.getOfferingTile(player); logger.info("player {} leaving offering {} " ,player.getNickname(), off); if(off!=null) off.setOccupant(null); logger.info("player {} left offering {} slot {} " ,player.getNickname(), off, slot); } // ========================================================================= // PHASE 2b — EXTRA DRAW (EXTRA_DRAW building effect) // ========================================================================= /** * Called when every offering tile has been resolved. * Checks if any player owns the EXTRA_DRAW building and, if so, enters * the EXTRA_DRAW phase. Otherwise proceeds directly to end of round. * * Rulebook appendix: "After resolving all actions (once all totems are back * on the TurnTile) and before the end-of-round phase, you may take 1 * Character or Building card (paying its cost) from the top row." */ private void onAllActionsResolved() { logger.info("All actions resolved."); extraDrawQueue.clear(); // Iterate in turn order so extra draws happen in a consistent sequence for (Player p : gameBoard.getTurnTile().getPositions()) { if (p != null && gameBoard.getBuildingManager().extraDraw(p)) { extraDrawQueue.add(p); } } if (!extraDrawQueue.isEmpty()) { state = GameState.EXTRA_DRAW; System.out.println("Extra draw phase: " + extraDrawQueue.get(0).getNickname() + " may draw first."); } else { endRound(); } } /** * The player uses their EXTRA_DRAW building to take one card from the top row. * Building cards may also be taken, paying the cost after builder discounts. * * @param player the player taking the extra draw * @param cardId the ID of the card they want from the top row * @return true if the action was accepted */ public ActionResult extraDrawAction(Player player, int cardId) { if (state != GameState.EXTRA_DRAW) { System.out.println("Error: not in EXTRA_DRAW phase."); return ActionResult.failure("Error: not in EXTRA_DRAW phase."); } if (extraDrawQueue.isEmpty() || extraDrawQueue.get(0) != player) { System.out.println("Error: it is not " + player.getNickname() + "'s extra draw turn."); return ActionResult.failure("Error: it is not " + player.getNickname() + "'s extra draw turn."); } Card card = gameBoard.takeFromTopRow(cardId); if (card == null) { System.out.println("Error: card " + cardId + " not found in top row."); return ActionResult.failure("Error: card " + cardId + " not found in top row."); } if (card instanceof EventCard) { gameBoard.getTopRow().add(card); System.out.println("Error: cannot take Event cards."); return ActionResult.failure("Error: cannot take Event cards."); } if (card instanceof BuildingCard) { ActionResult result = resolveBuildingCard(player, (BuildingCard) card); if (!result.isSuccess()) { gameBoard.getTopRow().add(card); return result; } } else { resolveCharacterCard(player, (CharacterCard) card); } advanceExtraDrawQueue(); return ActionResult.ok(); } /** * The player declines to use their EXTRA_DRAW building this round. * * @return true if accepted */ public boolean skipExtraDraw(Player player) { if (state != GameState.EXTRA_DRAW) return false; if (extraDrawQueue.isEmpty() || extraDrawQueue.get(0) != player) return false; System.out.println(player.getNickname() + " skips extra draw."); advanceExtraDrawQueue(); return true; } private void advanceExtraDrawQueue() { extraDrawQueue.remove(0); if (extraDrawQueue.isEmpty()) { endRound(); } else { System.out.println("Next extra draw: " + extraDrawQueue.get(0).getNickname()); } } // ========================================================================= // END OF ROUND // ========================================================================= /** * Runs all end-of-round steps in rulebook order: * * Step 1 — Resolve Event cards. * Normal rounds: only bottom row events. * Round 10: events from BOTH rows; then GAME_OVER. * Step 2 — Advance rows: discard bottom non-buildings, shift top * non-buildings down, draw (numPlayers + 4) new cards. * Step 3 — If a new Era card was revealed in step 2, trigger * the Era transition (building row swap). * Step 4 — Start the next round. */ private void endRound() { state = GameState.EVENT_RESOLUTION; //notify( GameState.EVENT_RESOLUTION); logger.info("--- End of round " + round + " ---"); boolean isFinalRound = (round == TOTAL_ROUNDS); // Step 1: resolve events resolveVisibleEvents(isFinalRound); if (isFinalRound) { state = GameState.GAME_OVER; notify( GameState.GAME_OVER, endGame()); logger.info("Round 10 complete — game over!"); return; } // Step 2: advance rows; returns true if a new Era card was revealed boolean eraChangeTriggered = gameBoard.advanceRows(players.size()); // Step 3: handle Era transition if needed if (eraChangeTriggered) { gameBoard.triggerEraChange(players.size()); } // Step 4: start next round startNewRound(); } /** * Resolves all visible Event cards in the correct order. * * Rules: * - Multiple events of different types: any order, Sustainment always last. * - Two events of the same type in the same round: resolve in Era order. * - Round 10: events from both rows must be resolved. * * @param bothRows true means events from both rows are included (round 10) */ private void resolveVisibleEvents(boolean bothRows) { List events = bothRows ? gameBoard.getAllVisibleEvents() // already sorted by Era ordinal : gameBoard.getVisibleEvents(); // already sorted by Era ordinal if (events.isEmpty()) { logger.info("No events to resolve this round."); return; } // Sustainment must always be resolved last List others = new ArrayList<>(); List sustainments = new ArrayList<>(); for (EventCard e : events) { if (e.getEvent() == Event.SUSTAINMENT) sustainments.add(e); else others.add(e); } for (EventCard e : others) resolveOneEvent(e); for (EventCard e : sustainments) resolveOneEvent(e); } private void resolveOneEvent(EventCard event) { logger.info(">>"+gameBoard.getTurnTile()); notify( GameState.EVENT_RESOLUTION, event.toString()); logger.info("SOLVE EVENT: " + event.getEvent() +" - " +event.getCardId() +" (Era " + event.getEra() + ")"); EventsSolver.solveEvents(Collections.singletonList(event), players); logger.info("<<"+gameBoard.getTurnTile()); } private void startNewRound() { round++; notify( GameState.NEXT_ROUND, "Round="+ round); logger.info("startNewRound: {}", round ); totemPlacedCount = 0; currentOfferingTileIndex = 0; pendingActions.clear(); gameBoard.clearOfferingTiles(); gameBoard.getTurnTile().resetTrack(); // notify( GameState.TOTEM_PLACEMENT); state = GameState.TOTEM_PLACEMENT; logger.info("=== NEW Round " + round + " ==="); logTurnOrder(); } // ========================================================================= // END GAME // ========================================================================= /** * Computes the final leaderboard and returns it as a formatted string. * * Final score = prestige points accumulated during play * + Tribe.endPoints() which covers: builder PP, inventor PP, * artist PP, building base PP, and building end-game abilities. * * IMPORTANT: do NOT call BuildingManager.endgameForSix() / * endgameBonusCharacter() / endgameBonusPoints() here. * Tribe.endPoints() already computes those bonuses. Calling both would * count them twice. * * Tie-breaking rule: most food wins. Further ties share the victory. */ public String endGame() { state = GameState.GAME_OVER; List ranked = new ArrayList<>(players); ranked.sort((a, b) -> { int diff = totalScore(b) - totalScore(a); if (diff != 0) return diff; return b.getFoodTokens() - a.getFoodTokens(); }); StringBuilder sb = new StringBuilder("=== FINAL LEADERBOARD ===\n"); for (int i = 0; i < ranked.size(); i++) { Player p = ranked.get(i); sb.append(i + 1).append(". ") .append(p.getNickname()) .append(" — ").append(totalScore(p)).append(" PP") .append(" (food: ").append(p.getFoodTokens()).append(")\n"); } Player winner = ranked.get(0); sb.append("\nWINNER: ") .append(winner.getNickname().toUpperCase()) .append(" with ").append(totalScore(winner)).append(" PP!"); return sb.toString(); } /** Prestige points accumulated during play + final scoring from the Tribe engine. */ private int totalScore(Player p) { return p.getPrestigePoints() + p.getPlayerTribe().endPoints(); } // ========================================================================= // HELPERS // ========================================================================= private void putCardBack(Card card, boolean wasTopRow) { if (wasTopRow) gameBoard.getTopRow().add(card); else gameBoard.getBottomRow().add(card); } private void logTurnOrder() { Player[] order = gameBoard.getTurnTile().getPositions(); StringBuilder sb = new StringBuilder("Turn order: "); for (int i = 0; i < order.length; i++) { if (order[i] != null) sb.append(i + 1).append(". ").append(order[i].getNickname()).append(" "); } logger.info("logTurnOrder: " + sb); } // ========================================================================= // GETTERS // ========================================================================= public List getPlayers() { return Collections.unmodifiableList(players); } public GameBoard getGameBoard() { return gameBoard; } public int getRound() { return round; } public GameState getState() { return state; } public void setState(GameState s) { this.state = s; } public List getPendingActions() { return Collections.unmodifiableList(pendingActions); } /** * Returns the player currently expected to act: * TOTEM_PLACEMENT — the next player who should place their totem * ACTION_RESOLUTION — the player currently resolving tile actions * EXTRA_DRAW — the next player to make their extra-draw decision */ public Player getCurrentPlayer() { switch (state) { case TOTEM_PLACEMENT: if (totemPlacedCount < players.size()) return gameBoard.getTurnTile().getPositions()[totemPlacedCount]; return null; case ACTION_RESOLUTION: List tiles = gameBoard.getOfferingTiles(); if (currentOfferingTileIndex < tiles.size()) return tiles.get(currentOfferingTileIndex).getOccupant(); return null; case EXTRA_DRAW: return extraDrawQueue.isEmpty() ? null : extraDrawQueue.get(0); default: return null; } } @Override public String toString() { return "Game{" + " state=" + state + ",\n round=" + round + ",\n totemPlacedCount=" + totemPlacedCount + ",\n currentOfferingTileIndex=" + currentOfferingTileIndex + ",\n pendingActions=" + pendingActions + ",\n extraDrawQueue=" + extraDrawQueue + " \n GameBoard=" + gameBoard + '}'; } public record GameEventNotification(GameState event, String message) {} public interface GameEventListener { void onEvent(GameEventNotification notification); } // Campo privato private GameEventListener eventListener; // Setter per il controller FX public void setEventListener(GameEventListener listener) { this.eventListener = listener; } // Metodi privati di notifica private void notify(GameState event, String message) { if (eventListener != null) eventListener.onEvent(new GameEventNotification(event, message)); } private void notify(GameState event) { notify(event, null); } }