diff --git a/pom.xml b/pom.xml index b05ab02..9f9c4d6 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,21 @@ gson 2.10.1 + + org.jline + jline-terminal + 3.26.3 + + + org.jline + jline-reader + 3.26.3 + + + org.jline + jline-builtins + 3.26.3 + @@ -137,6 +152,7 @@ it.polimi.ingsw.gc14.ClientLauncherTUI + @@ -145,6 +161,7 @@ META-INF/*.SF META-INF/*.DSA META-INF/*.RSA + META-INF/versions/9/module-info.class diff --git a/src/main/java/it/polimi/ingsw/gc14/ClientLauncherTUI.java b/src/main/java/it/polimi/ingsw/gc14/ClientLauncherTUI.java index 0dff39c..40b5806 100644 --- a/src/main/java/it/polimi/ingsw/gc14/ClientLauncherTUI.java +++ b/src/main/java/it/polimi/ingsw/gc14/ClientLauncherTUI.java @@ -1,210 +1,248 @@ package it.polimi.ingsw.gc14; + import it.polimi.ingsw.gc14.Controller.ClientController; -import it.polimi.ingsw.gc14.Model.Cards.TribeCards.Character; import it.polimi.ingsw.gc14.Model.GamePackage.GameStages; import it.polimi.ingsw.gc14.Network.NetworkConfig; import it.polimi.ingsw.gc14.Network.InterfaceResolver; -import it.polimi.ingsw.gc14.Network.NetworkEvent; import it.polimi.ingsw.gc14.Network.RMI.Client.RMIClient; import it.polimi.ingsw.gc14.Network.TCP.Client.TCPClient; import it.polimi.ingsw.gc14.View.TUI.TUI; -import java.net.*; -import java.util.ArrayList; -import java.util.Enumeration; -import java.util.List; -import java.util.Scanner; +import org.jline.builtins.Completers.TreeCompleter; +import org.jline.reader.*; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +import java.io.IOException; + +import static org.jline.builtins.Completers.TreeCompleter.node; /** * Entry point for the TUI-based game client. - * Handles the initial setup by asking the user for a username, the desired number of players, - * and the preferred network protocol (RMI or TCP). - * Once connected to the server, it continuously reads and dispatches user input to the controller. + * Handles login, connects to the server, and drives the JLine-powered command loop. */ public class ClientLauncherTUI { - List admissibleChar=new ArrayList<>(); + private TUI view; + private Terminal terminal; + private String currentUsername = ""; /** - * The TUI view associated with this client. - */ - TUI view; - - /** - * Starts the TUI client. - * Prompts the user for a username, the desired number of players, and the network protocol. - * Attempts to connect to the server using either RMI or TCP depending on the selection. - * If the connection is successful, enters a loop to continuously read and process user input. + * Starts the TUI client. Creates a JLine terminal, loops through login → game → rematch. * * @throws InterruptedException if the thread is interrupted while waiting. */ public void main() throws InterruptedException { + try { + terminal = TerminalBuilder.builder().system(true).build(); + } catch (IOException e) { + System.err.println("Could not initialise terminal: " + e.getMessage()); + return; + } + terminal.writer().print("\033[H\033[2J"); + terminal.writer().flush(); + view = new TUI(null); - admissibleChar.add("0"); - admissibleChar.add("1"); - admissibleChar.add("2"); - admissibleChar.add("3"); - admissibleChar.add("4"); - admissibleChar.add("5"); - admissibleChar.add("6"); - admissibleChar.add("7"); - admissibleChar.add("8"); - admissibleChar.add("A"); - admissibleChar.add("B"); - admissibleChar.add("C"); - admissibleChar.add("a"); - admissibleChar.add("b"); - admissibleChar.add("c"); ClientController controller = new ClientController(view); - Scanner scanner = new Scanner(System.in); + LineReader gameReader = buildGameReader(); + view.setLineReader(gameReader); + while (true) { - System.out.println("Insert username: "); - String username = scanner.next(); - view.setUsername(username); - controller.setMyUsername(username); - System.out.println("Insert preferred number of players: "); - int proposedNumPlayers = scanner.nextInt(); - System.out.println("Select RMI[0] o TCP[1]: "); - int networkType = scanner.nextInt(); - System.out.println("Insert server IP: "); - String IP = scanner.next(); + if (!doLogin(controller)) continue; - // RMI - if (networkType == 0) { - // Connect - String myIP; + while (controller.getClient() != null) { + String prompt = buildPrompt(controller); + String line; try { - myIP = InterfaceResolver.resolveLocalInterface(IP); - } catch (Exception e) { - throw new RuntimeException(e); - } - System.setProperty("java.rmi.server.hostname", myIP); - RMIClient client = new RMIClient(controller, IP, NetworkConfig.RMI_PORT, myIP); - ErrorType serverResponse=client.connect(username, proposedNumPlayers); - if (serverResponse==null) { - System.out.println("Succesfully connected to RMI server\n\n"); - } else { - System.out.println("RMI connection refused\n\n"); - controller.view.showError(serverResponse,serverResponse.toString()); - continue; - } - controller.setClient(client); - while (controller.getClient() != null) { - getInput(scanner, controller, username); - } - // TCP - } else if (networkType == 1) { - // Connect - TCPClient client = new TCPClient(controller, IP, NetworkConfig.TCP_PORT, NetworkConfig.HEARTBEAT_PORT); - ErrorType serverResponse=client.connect(username, proposedNumPlayers); - if (serverResponse==null) { - System.out.println("Succesfully connected to TCP server\n\n"); - } else { - System.out.println("TCP connection refused\n\n"); - controller.view.showError(serverResponse,serverResponse.toString()); - continue; - } - controller.setClient(client); - // Play - while (controller.getClient() != null) { - getInput(scanner, controller, username); - } - } - } - - } - - /** - * Reads a single action from the user and dispatches it to the controller. - * The action is identified by a string code. Most actions also require a position index - * (e.g. the index of the card to draw from a list), which is read as a second input. - * Actions that do not require a position (7, 8, 9, A, B, C) skip the position prompt. - *

- * Available actions: - *

    - *
  • {@code 0} - Choose a slot by index.
  • - *
  • {@code 1} - Draw an upper building card by index.
  • - *
  • {@code 2} - Draw an upper tribe card by index.
  • - *
  • {@code 3} - Draw a lower building card by index.
  • - *
  • {@code 4} - Draw a lower tribe card by index.
  • - *
  • {@code 5} - Pick an optional tribe card by index.
  • - *
  • {@code 6} - Pick an optional building card by index.
  • - *
  • {@code 7} - Skip the optional card choice.
  • - *
  • {@code 8} - Skip the upper draw.
  • - *
  • {@code 9} - Skip the lower draw.
  • - *
  • {@code A} - Render the full game view.
  • - *
  • {@code B} - Render the board view.
  • - *
  • {@code C} - Render the player view.
  • - *
- * - * @param scanner the scanner used to read user input. - * @param controller the client controller to which actions are dispatched. - * @param username the username of the current player. - */ - private void getInput(Scanner scanner, ClientController controller, String username) { - String action = scanner.nextLine().trim(); - int pos = -1; - if(controller.getClient() == null) - { - if(action.equals("0")) - { - System.out.println("Are you sure? Y/N"); - String c= scanner.next(); - if(c.equals("Y") || c.equals("y")) - { - System.exit(0); - } - } - return; - } - if(action.isEmpty()) - return; - if(admissibleChar.contains(action)) - { - if(action.equals("0")) - { - System.out.println("Are you sure? Y/N"); - String c= scanner.next(); - if(!c.equals("Y") && !c.equals("y")) - { - view.render(); - return; - } - }else if (!action.equals("6")&&!action.equals("8") && !action.equals("A") && !action.equals("B") && !action.equals("C")&&!action.equals("a") && !action.equals("b") && !action.equals("c")) { - try { - System.out.println("Insert the required position:"); - pos = scanner.nextInt(); - } catch (Exception e) { - view.showError(ErrorType.GENERIC_ERROR,"Invalid input(expected number)"); + line = gameReader.readLine(prompt); + } catch (UserInterruptException e) { + safeQuit(controller); + return; + } catch (EndOfFileException e) { + safeQuit(controller); return; } + if (line == null || line.isBlank()) continue; + boolean rematch = handleCommand(line.trim(), controller); + if (rematch) break; } - switch (action) { - case "1" -> controller.slotChoice(pos); - case "2" -> controller.drawUpperTribeCard(pos); - case "3" -> controller.drawUpperBuildingCard(pos); - case "4" -> controller.drawLowerTribeCard(pos); - case "5" -> controller.drawLowerBuildingCard(pos); - case "6" -> controller.skipTurn(); - case "7" -> controller.totemChoice(pos); - case "8" ->{ - if(controller.miniModel.currentState.getGameStage().equals(GameStages.ENDED)) { - controller.disconnect(); - controller.setClient(null); - controller.setModel(null); - } - } - case "A", "a" -> view.fullRender(); - case "B", "b" -> view.renderBoard(); - case "C", "c" -> view.renderPlayer(); - default -> {} - } - } - else - { - view.showError(ErrorType.GENERIC_ERROR,"Invalid input(expected number)"); } } + private boolean doLogin(ClientController controller) { + LineReader loginReader = LineReaderBuilder.builder() + .terminal(terminal) + .completer(new StringsCompleter("rmi", "tcp")) + .build(); + LineReader ipReader = LineReaderBuilder.builder() + .terminal(terminal) + .completer(new StringsCompleter("localhost")) + .build(); -} \ No newline at end of file + String username, networkStr, ip; + int nPlayers; + + try { + username = loginReader.readLine("Username: ").trim(); + nPlayers = Integer.parseInt(loginReader.readLine("Number of players [2-5]: ").trim()); + networkStr = loginReader.readLine("Network [rmi/tcp]: ").trim().toLowerCase(); + ip = ipReader.readLine("Server IP [localhost]: ").trim(); + if (ip.isEmpty()) ip = "localhost"; + } catch (UserInterruptException | EndOfFileException e) { + System.exit(0); + return false; + } catch (NumberFormatException e) { + System.out.println("Invalid number of players."); + return false; + } + + currentUsername = username; + view.setUsername(username); + controller.setMyUsername(username); + + if (networkStr.equals("rmi")) { + String myIP; + try { + myIP = InterfaceResolver.resolveLocalInterface(ip); + } catch (Exception e) { + System.out.println("Cannot resolve local interface: " + e.getMessage()); + return false; + } + System.setProperty("java.rmi.server.hostname", myIP); + RMIClient client = new RMIClient(controller, ip, NetworkConfig.RMI_PORT, myIP); + ErrorType err = client.connect(username, nPlayers); + if (err != null) { + view.showError(err, err.toString()); + return false; + } + System.out.println("Connected via RMI."); + controller.setClient(client); + + } else if (networkStr.equals("tcp")) { + TCPClient client = new TCPClient(controller, ip, NetworkConfig.TCP_PORT, NetworkConfig.HEARTBEAT_PORT); + ErrorType err = client.connect(username, nPlayers); + if (err != null) { + view.showError(err, err.toString()); + return false; + } + System.out.println("Connected via TCP."); + controller.setClient(client); + + } else { + System.out.println("Unknown network type '" + networkStr + "'. Use rmi or tcp."); + return false; + } + return true; + } + + private LineReader buildGameReader() { + Completer completer = new TreeCompleter( + node("slot"), + node("draw", + node("upper", + node("tribe"), + node("building")), + node("lower", + node("tribe"), + node("building"))), + node("totem"), + node("skip"), + node("render", + node("full"), + node("board"), + node("players")), + node("rematch"), + node("quit") + ); + return LineReaderBuilder.builder() + .terminal(terminal) + .completer(completer) + .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true) + .build(); + } + + private String buildPrompt(ClientController controller) { + return currentUsername.isEmpty() ? "> " : currentUsername + "> "; + } + + /** Returns {@code true} when the caller should break out of the game loop (rematch requested). */ + private boolean handleCommand(String line, ClientController controller) { + String[] parts = line.split("\\s+"); + String cmd = parts[0].toLowerCase(); + + switch (cmd) { + case "slot" -> { + int pos = parsePos(parts, 1); + if (pos >= 0) controller.slotChoice(pos); + } + case "draw" -> handleDraw(parts, controller); + case "totem" -> { + int pos = parsePos(parts, 1); + if (pos >= 0) controller.totemChoice(pos); + } + case "skip" -> controller.skipTurn(); + case "render" -> handleRender(parts); + case "rematch" -> { + if (controller.miniModel == null + || !controller.miniModel.currentState.getGameStage().equals(GameStages.ENDED)) { + System.out.println("Game not ended yet. Use 'quit' to disconnect."); + return false; + } + controller.disconnect(); + controller.setClient(null); + controller.setModel(null); + return true; + } + case "quit" -> { + safeQuit(controller); + System.exit(0); + } + default -> view.showError(ErrorType.GENERIC_ERROR, "Unknown command: " + cmd); + } + return false; + } + + private void handleDraw(String[] parts, ClientController controller) { + if (parts.length < 4) { + view.showError(ErrorType.GENERIC_ERROR, "Usage: draw upper|lower tribe|building "); + return; + } + boolean upper = parts[1].equalsIgnoreCase("upper"); + boolean tribe = parts[2].equalsIgnoreCase("tribe"); + int pos = parsePos(parts, 3); + if (pos < 0) return; + + if (upper && tribe) controller.drawUpperTribeCard(pos); + else if (upper) controller.drawUpperBuildingCard(pos); + else if (tribe) controller.drawLowerTribeCard(pos); + else controller.drawLowerBuildingCard(pos); + } + + private void handleRender(String[] parts) { + String sub = parts.length > 1 ? parts[1].toLowerCase() : "board"; + switch (sub) { + case "full" -> view.fullRender(); + case "players" -> view.renderPlayer(); + default -> view.renderBoard(); + } + } + + private int parsePos(String[] parts, int idx) { + if (parts.length <= idx) { + view.showError(ErrorType.GENERIC_ERROR, "Missing position argument."); + return -1; + } + try { + return Integer.parseInt(parts[idx]); + } catch (NumberFormatException e) { + view.showError(ErrorType.GENERIC_ERROR, + "Invalid position '" + parts[idx] + "' — expected integer."); + return -1; + } + } + + private void safeQuit(ClientController controller) { + if (controller.getClient() != null) controller.disconnect(); + } +} diff --git a/src/main/java/it/polimi/ingsw/gc14/View/TUI/TUI.java b/src/main/java/it/polimi/ingsw/gc14/View/TUI/TUI.java index 9c771ea..a3b5403 100644 --- a/src/main/java/it/polimi/ingsw/gc14/View/TUI/TUI.java +++ b/src/main/java/it/polimi/ingsw/gc14/View/TUI/TUI.java @@ -5,6 +5,9 @@ import it.polimi.ingsw.gc14.Model.GamePackage.GameStages; import it.polimi.ingsw.gc14.Network.NetworkEvent; import it.polimi.ingsw.gc14.View.IView; +import org.jline.reader.LineReader; +import org.jline.terminal.Terminal; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -13,358 +16,347 @@ import java.util.Map; /** * Text-based User Interface (TUI) implementation of {@link IView}. * - *

Renders the current state of a {@link Game} model directly to the - * standard output using Unicode box-drawing characters and fixed-width - * ASCII tables (see {@link AsciiTable}). + *

Renders the current state of a {@link MiniModel} directly to the + * terminal using Unicode box-drawing characters and fixed-width ASCII tables. * - *

The display is split into two side-by-side panels: - *

    - *
  • Left panel — board or player status, depending on the render method called.
  • - *
  • Right panel — menu options legend and the current player's hand.
  • - *
- * - *

The terminal is cleared before each render via a platform-aware - * {@code cls} / {@code clear} system call. - * - *

Typical usage: - *

{@code
- * TUI tui = new TUI(game);
- * tui.setUsername("Alice");
- * tui.fullRender();
- * }
+ *

All output goes through {@link #display(String)}, which uses + * {@link LineReader#printAbove} when a JLine reader is set so that the + * readline prompt is correctly redrawn after background-thread renders. */ public class TUI implements IView { - /** - * The {@link MiniModel} model whose state is rendered. - */ private MiniModel model; - - /** - * The username of the local player, used to retrieve and display - * that player's hand in the right panel. - * - * @see #setUsername(String) - */ private String username; /** - * Constructs a {@code TUI} bound to the given game model. - * The username is initialised to an empty string and must be set - * separately via {@link #setUsername(String)} before calling any - * render method that displays the player's hand. + * JLine reader — when non-null, all output uses {@code printAbove} so the + * readline prompt is preserved after background-thread renders. + */ + private LineReader lineReader; + + /** JLine terminal — used to query terminal width for centering. */ + private Terminal terminal; + + /** + * Constructs a {@code TUI} bound to the given model. * - * @param model the {@link Game} model to display; must not be {@code null} + * @param model the model to display; may be {@code null} initially. */ public TUI(MiniModel model) { - this.model = model; + this.model = model; this.username = ""; } - /** - * Sets the username of the local player. - * This value is used by {@link #renderBoard()} and {@link #fullRender()} - * to look up the correct player hand via - * {@link Game#getPlayerByUsername(String)}. - * - * @param username the player's username; must match an existing player - * in the current {@link Game} model - */ + // ── Setters ─────────────────────────────────────────────────────────────── + + /** Sets the username of the local player. */ public void setUsername(String username) { this.username = username; } - /** - * Updates the game model stored in this view. - * Should be called whenever the game state changes so that the next - * render reflects the latest state. - * - * @param model the new {@link Game} model; must not be {@code null} - */ + /** Updates the model stored in this view. */ @Override public void setModel(MiniModel model) { this.model = model; } /** - * Default render entry point, as required by {@link IView}. - * Delegates to {@link #renderBoard()}. + * Registers the active JLine {@link LineReader}. + * Once set, all output routes through {@link LineReader#printAbove} so the + * prompt survives background-thread renders. + */ + public void setLineReader(LineReader lineReader) { + this.lineReader = lineReader; + this.terminal = lineReader.getTerminal(); + } + + // ── Public render entry points ──────────────────────────────────────────── + + /** + * Default render: dispatches to the right sub-renderer based on game stage. */ @Override public synchronized void render() { - if(model != null) - { - if(model.currentState.getGameStage().equals(GameStages.TOTEM_CHOICE)) - { - renderTotems(); - } - else if(model.currentState.getGameStage().equals(GameStages.ENDED)) - { - renderStanding(); - System.out.println("Press 8 to start another game or press 0 to exit"); - return; - } - else - { - renderBoard(); - } - System.out.println("\nYOUR ACTION:"); + if (model == null) return; + GameStages stage = model.currentState.getGameStage(); + if (stage.equals(GameStages.TOTEM_CHOICE)) { + display(buildTotemsContent()); + } else if (stage.equals(GameStages.ENDED)) { + display(buildStandingContent() + "\nType 'rematch' to play again or 'quit' to exit"); + } else { + display(buildBoardContent()); } } - /** Prints the final standings side-by-side in pairs, then announces the winner or game-over result. */ - private void renderStanding() { - if(model.standingPlayers!=null) - { - StringBuilder stringBuilder=new StringBuilder(); - for(int i = 0; i< model.standingPlayers.size()/2; i++) - { - stringBuilder.append(AsciiTable.sideBySide(List.of(model.standingPlayers.get((i*2)).toString().split("\n")),List.of(model.standingPlayers.get((i*2+1)).toString().split("\n")),2)); - } - stringBuilder.append("\n"); - if(model.standingPlayers.size()%2!=0) - { - stringBuilder.append(model.standingPlayers.get(model.standingPlayers.size()-1).toString()); - stringBuilder.append("\n"); - } - System.out.println(stringBuilder); - if(model.standingPlayers.get(0).getUserName().equals(username)) - { - System.out.println("GAME ENDED: !!!YOU WON!!!"); - } - else { - System.out.println("GAME ENDED: !!!YOU LOST!!!"); - } - } - } - - /** - * Renders a full view of the game, combining both player status - * and board status. - * - *

Layout: - *

    - *
  • Top — player status table produced by - * {@link #PlayersStamp()}.
  • - *
  • Bottom-left — board status produced by - * {@link #BoardStamp()}.
  • - *
  • Bottom-right — menu options legend and the local - * player's hand.
  • - *
- * - *

The terminal is cleared before rendering. - */ - public void fullRender() { - clearTerminal(); - List lines = List.of(BoardStamp().split("\n")); - List lines2 = List.of((printMenuOptions() + "\n" + - model.players.get(username)).split("\n")); - System.out.println(PlayersStamp() + "\n" + - AsciiTable.sideBySide(lines, lines2, 3)); - } - - /** - * Renders the board status only. - * - *

Layout: - *

    - *
  • Left panel — turn order, upper card row, offer track, - * and lower card row, as produced by {@link #BoardStamp()}.
  • - *
  • Right panel — menu options legend followed by the local - * player's hand.
  • - *
- * - *

The terminal is cleared before rendering. - */ + /** Renders the board panel + menu + player hand side by side. */ public void renderBoard() { - clearTerminal(); - List lines = List.of(BoardStamp().split("\n")); - List lines2 = List.of((printMenuOptions() + "\nYOUR HAND\n" + - model.players.get(username)).split("\n")); - System.out.println(AsciiTable.sideBySide(lines, lines2, 3)); + display(buildBoardContent()); + } + + /** Renders players panel + board panel + menu + player hand. */ + public void fullRender() { + display(buildFullContent()); + } + + /** Renders the totem-choice panel. */ + public void renderTotems() { + display(buildTotemsContent()); + } + + /** Renders the players panel + menu side by side. */ + public void renderPlayer() { + display(buildPlayerContent()); } /** - * Renders the available totems in the terminal interface. + * Shows an error message above (or below) the current board. * - *

The method clears the terminal, builds a table containing the currently - * available totems together with their selection positions, and displays the - * instructions required to choose one. + *

Board and message are combined into a single {@link #display} call so + * that only one {@code printAbove} is issued — avoiding the double-clear + * that would occur if render and message were displayed separately. + * + * @param error the error type. + * @param message the human-readable message to append. */ - public void renderTotems() { - clearTerminal(); + public void showError(ErrorType error, String message) { + if (model == null) { + display(message); + return; + } + GameStages stage = model.currentState.getGameStage(); + String base; + if (stage.equals(GameStages.TOTEM_CHOICE)) { + base = buildTotemsContent(); + } else if (stage.equals(GameStages.ENDED)) { + base = buildStandingContent() + "\nType 'rematch' to play again or 'quit' to exit"; + } else { + base = buildBoardContent(); + } + + if (error.equals(ErrorType.WRONG_ACTION) + && model.currentState.getCurrentPlayer() != null + && !model.currentState.getCurrentPlayer().getUserName().equals(username)) { + display(base + "\nIt's not your turn"); + return; + } + + String full = base + "\n" + message; + if (error == ErrorType.SERVER_CRASHED) { + full += "\nPress any key to continue"; + } + display(full); + } + + // ── Content builders (return strings, do not print) ─────────────────────── + + private String buildBoardContent() { + List left = List.of(BoardStamp().split("\n")); + List right = List.of((printMenuOptions() + "\nYOUR HAND\n" + + model.players.get(username)).split("\n")); + return AsciiTable.sideBySide(left, right, 3); + } + + private String buildFullContent() { + List left = List.of(BoardStamp().split("\n")); + List right = List.of((printMenuOptions() + "\n" + + model.players.get(username)).split("\n")); + return PlayersStamp() + "\n" + AsciiTable.sideBySide(left, right, 3); + } + + private String buildPlayerContent() { + List left = List.of(PlayersStamp().split("\n")); + List right = List.of(printMenuOptions().split("\n")); + return AsciiTable.sideBySide(left, right, 3); + } + + private String buildTotemsContent() { var table = new AsciiTable(BorderStyle.ROUNDED, model.availableTotems.size()); - List lines =new ArrayList<>(); - for(int i=0;i lines = new ArrayList<>(); + for (int i = 0; i < model.availableTotems.size(); i++) { + lines.add(i + "." + model.availableTotems.get(i)); } table.addRow(lines); - System.out.println(model.currentState+"\nTOTEMS AVAILABLE:\n"+table.build()+"\nPress 7 and then write the position"); + String current = model.currentState.getCurrentPlayer() != null + ? model.currentState.getCurrentPlayer().getUserName() + " — " + : ""; + return current + "TOTEMS AVAILABLE:\n" + table.build() + "\nType: totem "; } - /** - * Renders the player status table only. - * - *

Layout:SkipTurn - *

    - *
  • Left panel — prestige, food, character deck, and building - * deck for all players, as produced by {@link #PlayersStamp()}.
  • - *
  • Right panel — menu options legend.
  • - *
- * - *

The terminal is cleared before rendering. - */ - public void renderPlayer() { - clearTerminal(); - List lines = List.of(PlayersStamp().split("\n")); - List lines2 = List.of(printMenuOptions().split("\n")); - System.out.println(AsciiTable.sideBySide(lines, lines2, 3)); - } - - - /** - * Prints an error message to standard output. - * - * @param error the error message to display - */ - public void showError(ErrorType error,String message) { - clearTerminal(); - render(); - if(error.equals(ErrorType.WRONG_ACTION)) - { - if(!model.currentState.getCurrentPlayer().getUserName().equals(username)){ - System.out.println("It's not your turn"); - return; - } + private String buildStandingContent() { + if (model.standingPlayers == null) return ""; + StringBuilder sb = new StringBuilder(); + int size = model.standingPlayers.size(); + for (int i = 0; i < size / 2; i++) { + sb.append(AsciiTable.sideBySide( + List.of(model.standingPlayers.get(i * 2).toString().split("\n")), + List.of(model.standingPlayers.get(i * 2 + 1).toString().split("\n")), 2)); + } + sb.append("\n"); + if (size % 2 != 0) { + sb.append(model.standingPlayers.get(size - 1)).append("\n"); + } + if (model.standingPlayers.get(0).getUserName().equals(username)) { + sb.append("GAME ENDED: !!!YOU WON!!!"); + } else { + sb.append("GAME ENDED: !!!YOU LOST!!!"); + } + return sb.toString(); + } + + // ── Core display primitive ──────────────────────────────────────────────── + + /** + * Clears the terminal and displays {@code content}, horizontally centered. + * + *

When a JLine {@link LineReader} is registered, output is routed through + * {@link LineReader#printAbove} which pauses readline, prints the content, + * and redraws the prompt — safe to call from any thread. + * Otherwise, ANSI codes are written directly to stdout. + * + * @param content the text to display (board, error, etc.). + */ + private void display(String content) { + String cleared = "\033[H\033[2J" + center(content); + if (lineReader != null) { + lineReader.printAbove(cleared); + } else { + System.out.print(cleared); + System.out.println(); + System.out.flush(); } - System.out.println(message); - if(error==ErrorType.SERVER_CRASHED) - System.out.println("Press any key to continue"); } /** - * Builds and returns the menu options panel as a two-column - * {@link AsciiTable} with rounded borders. - * - *

The left column lists game action commands (slot choice, draw, pick…), - * the right column lists render shortcuts (full, board, players). - * - * @return the rendered menu table as a multi-line string + * Horizontally centers each line of {@code content} within the terminal width. + * Empty lines are left unpadded. Falls back to the original string when the + * terminal width is unknown. */ + private String center(String content) { + if (terminal == null) return content; + int termWidth = terminal.getWidth(); + if (termWidth <= 0) return content; + + String[] lines = content.split("\n", -1); + + int maxWidth = 0; + for (String line : lines) { + int w = visibleLength(line); + if (w > maxWidth) maxWidth = w; + } + + int pad = Math.max(0, (termWidth - maxWidth) / 2); + if (pad == 0) return content; + + String prefix = " ".repeat(pad); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].isEmpty()) sb.append(prefix); + sb.append(lines[i]); + if (i < lines.length - 1) sb.append("\n"); + } + return sb.toString(); + } + + /** Returns the visible (printable) length of a string, stripping ANSI escape codes. */ + private int visibleLength(String line) { + return line.replaceAll("\033\\[[^m]*m", "").length(); + } + + // ── Board / players stamp helpers ───────────────────────────────────────── + + /** + * Returns all players rendered side-by-side in pairs. + * + * @return multi-line string with all player panels. + */ + public String PlayersStamp() { + StringBuilder sb = new StringBuilder(); + ArrayList list = new ArrayList<>(model.players.values()); + for (int i = 0; i < list.size() / 2; i++) { + sb.append(AsciiTable.sideBySide( + List.of(list.get(i * 2).toString().split("\n")), + List.of(list.get(i * 2 + 1).toString().split("\n")), 2)); + } + sb.append("\n"); + if (list.size() % 2 != 0) { + sb.append(list.get(list.size() - 1)).append("\n"); + } + return sb.toString(); + } + + /** + * Returns the board state as a multi-line string: turn order, upper cards, + * offer track, lower cards. + * + * @return multi-line board string. + */ + public String BoardStamp() { + var offerTrack = new AsciiTable(BorderStyle.UNICODE, model.slotPlayerMap.size()); + List slotNames = new ArrayList<>(); + List slotPlayers = new ArrayList<>(); + + int index = 0; + for (Map.Entry entry : model.slotPlayerMap.entrySet()) { + slotNames.add((index++) + "." + entry.getKey().toStringTUI()); + slotPlayers.add(entry.getValue() != null ? entry.getValue().getUserName() : " "); + } + + var TribeUpper = new AsciiTable(BorderStyle.UNICODE, 1); + var TribeLower = new AsciiTable(BorderStyle.UNICODE, 1); + var BuildUpper = new AsciiTable(BorderStyle.UNICODE, 1); + BuildUpper.addHeader("Building "); + var BuildLower = new AsciiTable(BorderStyle.UNICODE, 1); + BuildLower.addHeader("Building "); + + List upperTribeRows = new ArrayList<>(); + List lowerTribeRows = new ArrayList<>(); + upperTribeRows.add("Char/Events"); + lowerTribeRows.add("Char/Events"); + + int max = Math.max(model.upperListTribeCards.size(), model.lowerListTribeCards.size()); + for (int i = 0; i < max; i++) { + if (i < model.upperListTribeCards.size()) + upperTribeRows.add(i + "-" + model.upperListTribeCards.get(i).toStringBoard()); + if (i < model.lowerListTribeCards.size()) + lowerTribeRows.add(i + "-" + model.lowerListTribeCards.get(i).toStringBoard()); + } + + int maxB = Math.max(model.upperListBuildingCards.size(), model.lowerListBuildingCards.size()); + for (int i = 0; i < maxB; i++) { + if (i < model.upperListBuildingCards.size()) + BuildUpper.addRow(i + "-" + model.upperListBuildingCards.get(i).toString()); + if (i < model.lowerListBuildingCards.size()) + BuildLower.addRow(i + "-" + model.lowerListBuildingCards.get(i).toString()); + } + + upperTribeRows.forEach(TribeUpper::addRow); + lowerTribeRows.forEach(TribeLower::addRow); + offerTrack.addRow(slotPlayers); + offerTrack.addRow(slotNames); + + return "CURRENT STATE\n" + model.currentState + "\n" + model.orderLogicCard + "\n" + + AsciiTable.sideBySide( + Arrays.stream(TribeUpper.build().split("\n")).toList(), + Arrays.stream(BuildUpper.build().split("\n")).toList(), 2) + + "\n" + offerTrack.build() + "\n" + + AsciiTable.sideBySide( + List.of(TribeLower.build().split("\n")), + Arrays.stream(BuildLower.build().split("\n")).toList(), 2); + } + + // ── Menu options ────────────────────────────────────────────────────────── + private String printMenuOptions() { var table = new AsciiTable(BorderStyle.ROUNDED, 2); - table.addHeader("Menu Options", "Render Options"); - table.addRow(List.of("1-SlotChoice(pos)", "A-Full Render")); - table.addRow(List.of("2-DrawUpperTribe(pos)", "B-Board Render")); - table.addRow(List.of("3-DrawUpperBuilding(pos)", "C-Players Render")); - table.addRow(List.of("4-DrawLowerTribe(pos)", "")); - table.addRow(List.of("5-DrawLowerBuilding(pos)", "")); - table.addRow(List.of("6-SkipTurn", "")); - table.addRow(List.of("0-Disconnect", "")); + table.addHeader("Commands", "Render"); + table.addRow(List.of("slot ", "render full")); + table.addRow(List.of("draw upper tribe ", "render board")); + table.addRow(List.of("draw upper building ", "render players")); + table.addRow(List.of("draw lower tribe ", "")); + table.addRow(List.of("draw lower building ", "")); + table.addRow(List.of("totem ", "")); + table.addRow(List.of("skip", "")); + table.addRow(List.of("rematch | quit", "")); return table.build(); } - - /** - * Clears the terminal using a platform-aware system call. - * Uses {@code cls} on Windows and {@code clear} on Unix-like systems. - * Failures are silently ignored to avoid interrupting the render flow. - */ - private void clearTerminal() { - try { - String os = System.getProperty("os.name").toLowerCase(); - ProcessBuilder pb = os.contains("win") - ? new ProcessBuilder("cmd", "/c", "cls") - : new ProcessBuilder("clear"); - pb.inheritIO().start().waitFor(); - } catch (Exception ignored) {} - } - - /** - * Returns a string representation of all the players currently in the game, - * arranged side by side in pairs. - * If the number of players is odd, the last player is printed on its own line. - * - * @return {@code String} - a string representation of all the players. - */ - public String PlayersStamp() - { - StringBuilder stringBuilder=new StringBuilder(); - ArrayList playersList=new ArrayList<>(model.players.values()); - for(int i = 0; i< model.players.size()/2; i++) - { - stringBuilder.append(AsciiTable.sideBySide(List.of(playersList.get((i*2)).toString().split("\n")),List.of(playersList.get((i*2+1)).toString().split("\n")),2)); - } - stringBuilder.append("\n"); - if(playersList.size()%2!=0) - { - stringBuilder.append(playersList.get(playersList.size()-1).toString()); - stringBuilder.append("\n"); - } - return stringBuilder.toString(); - } - - /** - * Returns a string representation of the board, including the current state, - * the offer track with slot assignments, the upper and lower tribe card lists, - * and the upper and lower building card lists. - * - * @return {@code String} - a string representation of the board. - */ - public String BoardStamp() - { - var offerTrack = new AsciiTable(BorderStyle.UNICODE, model.slotPlayerMap.size()); - List stringUpOffer=new ArrayList<>(); - List stringDownOffer=new ArrayList<>(); - - int index=0; - for(Map.Entry entry:model.slotPlayerMap.entrySet()) - { - stringDownOffer.add((index++)+"."+entry.getKey().toStringTUI()); - if(entry.getValue()!=null) - stringUpOffer.add(entry.getValue().getUserName()); - else - stringUpOffer.add(" "); - } - - var TribeTableUpper = new AsciiTable(BorderStyle.UNICODE,1); - var TribeTableLower = new AsciiTable(BorderStyle.UNICODE,1); - - List stringUpperListTribe=new ArrayList<>(); - List stringLowerListTribe=new ArrayList<>(); - stringUpperListTribe.add("Char/Events"); - stringLowerListTribe.add("Char/Events"); - for(int i=0;i