diff --git a/README.md b/README.md index f9f08ca..a97e018 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ A simple number guessing game where you try to guess a randomly generated number **Features:** - Swing-based GUI (default) - Console mode (use `--console` flag) +- High score tracking with usernames +- Persistent score storage - Cross-platform ## Installation & Running @@ -83,10 +85,13 @@ The game now features both a graphical user interface (GUI) and a console mode. - Orange text indicates your guess was too high - Green text indicates you guessed correctly! 5. The number of guesses is displayed and updated in real-time -6. When you guess correctly, click "New Game" to play again -7. Use the menu bar for additional options: +6. When you guess correctly, you'll be prompted to enter your username +7. After entering your username, your score will be saved and the top high scores will be displayed +8. Click "New Game" to play again +9. Use the menu bar for additional options: - File → New Game (Ctrl+N): Start a new game - File → Exit: Close the application + - View → High Scores: View the top high scores - Help → About: View game information #### Console Mode @@ -105,6 +110,18 @@ In console mode: 2. The game will tell you if your guess is too high or too low 3. Keep guessing until you find the correct number 4. The game will display how many guesses it took you +5. Enter your username when prompted to save your score +6. The top high scores will be displayed after saving your score + +### High Scores + +The game automatically tracks high scores (games won with the fewest guesses). High scores are stored persistently in your home directory at `~/.numberguessinggame/highscores.properties`. + +- The top 10 scores are kept +- Scores are sorted by the number of guesses (fewest is best) +- Each score includes the username and number of guesses +- High scores persist across game sessions +- View high scores from the GUI menu (View → High Scores) or after completing a game ## Development diff --git a/app/src/main/java/io/github/project516/NumberGuessingGame/GUI.java b/app/src/main/java/io/github/project516/NumberGuessingGame/GUI.java index 38c991f..fc90d13 100644 --- a/app/src/main/java/io/github/project516/NumberGuessingGame/GUI.java +++ b/app/src/main/java/io/github/project516/NumberGuessingGame/GUI.java @@ -16,6 +16,7 @@ public class GUI extends JFrame { private RandomNumber randomGenerator; private CheckGuess guessChecker; private GameInfo gameInfo; + private HighScoreManager highScoreManager; // UI Components private JTextField guessField; @@ -38,6 +39,15 @@ public GUI() { guessChecker = new CheckGuess(); gameInfo = new GameInfo(); + // Initialize high score manager + try { + highScoreManager = new HighScoreManager(); + } catch (Exception e) { + System.err.println( + "Warning: Could not initialize high score system: " + e.getMessage()); + highScoreManager = null; + } + // Set up the frame setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(500, 400); @@ -76,6 +86,14 @@ private void createMenuBar() { exitItem.addActionListener(e -> System.exit(0)); fileMenu.add(exitItem); + // View menu + JMenu viewMenu = new JMenu("View"); + viewMenu.setMnemonic(KeyEvent.VK_V); + + JMenuItem highScoresItem = new JMenuItem("High Scores", KeyEvent.VK_H); + highScoresItem.addActionListener(e -> showHighScores()); + viewMenu.add(highScoresItem); + // Help menu JMenu helpMenu = new JMenu("Help"); helpMenu.setMnemonic(KeyEvent.VK_H); @@ -85,6 +103,7 @@ private void createMenuBar() { helpMenu.add(aboutItem); menuBar.add(fileMenu); + menuBar.add(viewMenu); menuBar.add(helpMenu); setJMenuBar(menuBar); @@ -223,13 +242,8 @@ private void submitGuess() { newGameButton.setVisible(true); newGameButton.requestFocus(); - // Show congratulations dialog - String message = - String.format( - "You guessed the number in %d %s!", - numberOfGuesses, numberOfGuesses == 1 ? "guess" : "guesses"); - JOptionPane.showMessageDialog( - this, message, "Congratulations!", JOptionPane.INFORMATION_MESSAGE); + // Handle high score + handleHighScore(); } guessField.setText(""); @@ -270,6 +284,63 @@ private void showAboutDialog() { this, message, "About Number Guessing Game", JOptionPane.INFORMATION_MESSAGE); } + /** + * Handles high score submission after a successful game. Prompts the user for their username + * and saves the score if applicable. + */ + private void handleHighScore() { + if (highScoreManager == null) { + // Show basic congratulations dialog if high score system is unavailable + String message = + String.format( + "You guessed the number in %d %s!", + numberOfGuesses, numberOfGuesses == 1 ? "guess" : "guesses"); + JOptionPane.showMessageDialog( + this, message, "Congratulations!", JOptionPane.INFORMATION_MESSAGE); + return; + } + + // Prompt for username + String username = + JOptionPane.showInputDialog( + this, + String.format( + "You guessed the number in %d %s!\n\nEnter your username (1-20 characters):", + numberOfGuesses, numberOfGuesses == 1 ? "guess" : "guesses"), + "Congratulations!", + JOptionPane.QUESTION_MESSAGE); + + // Validate username + if (username != null && Username.isValid(username)) { + highScoreManager.addHighScore(username, numberOfGuesses); + showHighScores(); + } else if (username != null) { + JOptionPane.showMessageDialog( + this, "Invalid username. Score not saved.", "Error", JOptionPane.ERROR_MESSAGE); + } + } + + /** Displays the top high scores in a dialog. */ + private void showHighScores() { + if (highScoreManager == null) { + return; + } + + java.util.List topScores = highScoreManager.getTopHighScores(10); + if (topScores.isEmpty()) { + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append("Top High Scores\n\n"); + for (int i = 0; i < topScores.size(); i++) { + sb.append(String.format("%d. %s\n", i + 1, topScores.get(i))); + } + + JOptionPane.showMessageDialog( + this, sb.toString(), "High Scores", JOptionPane.INFORMATION_MESSAGE); + } + /** * Gets the version of the application. * diff --git a/app/src/main/java/io/github/project516/NumberGuessingGame/GameLogic.java b/app/src/main/java/io/github/project516/NumberGuessingGame/GameLogic.java index 7f750e7..3ce8a77 100644 --- a/app/src/main/java/io/github/project516/NumberGuessingGame/GameLogic.java +++ b/app/src/main/java/io/github/project516/NumberGuessingGame/GameLogic.java @@ -1,10 +1,25 @@ package io.github.project516.NumberGuessingGame; +import java.util.List; + /** * Contains the main game logic for the Number Guessing Game. This class manages the game loop, user * input, and guess validation. */ public class GameLogic { + private HighScoreManager highScoreManager; + + /** Constructs a new GameLogic instance and initializes the high score manager. */ + public GameLogic() { + try { + highScoreManager = new HighScoreManager(); + } catch (Exception e) { + System.err.println( + "Warning: Could not initialize high score system: " + e.getMessage()); + highScoreManager = null; + } + } + /** * Runs the main game loop. Generates a random number and prompts the user to guess it. Provides * feedback on each guess and tracks the number of attempts. @@ -30,9 +45,36 @@ void game(ScannerHelper scan) { } else { numOfGuesses++; System.out.println("Took you " + numOfGuesses + " guesses!"); + + // Handle high score + if (highScoreManager != null) { + String username = Username.promptUsername(scan); + if (highScoreManager.addHighScore(username, numOfGuesses)) { + System.out.println("Score saved!"); + } + displayHighScores(); + } break; } numOfGuesses++; } } + + /** Displays the top high scores to the console. */ + private void displayHighScores() { + if (highScoreManager == null) { + return; + } + + List topScores = highScoreManager.getTopHighScores(5); + if (topScores.isEmpty()) { + return; + } + + System.out.println("\n=== Top High Scores ==="); + for (int i = 0; i < topScores.size(); i++) { + System.out.println((i + 1) + ". " + topScores.get(i)); + } + System.out.println("=======================\n"); + } } diff --git a/app/src/main/java/io/github/project516/NumberGuessingGame/HighScore.java b/app/src/main/java/io/github/project516/NumberGuessingGame/HighScore.java new file mode 100644 index 0000000..b5446f2 --- /dev/null +++ b/app/src/main/java/io/github/project516/NumberGuessingGame/HighScore.java @@ -0,0 +1,65 @@ +package io.github.project516.NumberGuessingGame; + +/** + * Represents a single high score entry in the Number Guessing Game. Each entry contains a username + * and the number of guesses it took to win the game. + */ +public class HighScore implements Comparable { + private final String username; + private final int numberOfGuesses; + + /** + * Constructs a new HighScore entry. + * + * @param username the username of the player + * @param numberOfGuesses the number of guesses it took to win + * @throws IllegalArgumentException if username is null or empty, or if numberOfGuesses is less + * than 1 + */ + public HighScore(String username, int numberOfGuesses) { + if (username == null || username.trim().isEmpty()) { + throw new IllegalArgumentException("Username cannot be null or empty"); + } + if (numberOfGuesses < 1) { + throw new IllegalArgumentException("Number of guesses must be at least 1"); + } + this.username = username.trim(); + this.numberOfGuesses = numberOfGuesses; + } + + /** + * Gets the username for this high score entry. + * + * @return the username + */ + public String getUsername() { + return username; + } + + /** + * Gets the number of guesses for this high score entry. + * + * @return the number of guesses + */ + public int getNumberOfGuesses() { + return numberOfGuesses; + } + + /** + * Compares this high score to another. High scores are ordered by number of guesses (ascending) + * so that lower scores (fewer guesses) come first. + * + * @param other the other high score to compare to + * @return a negative integer, zero, or a positive integer as this high score is less than, + * equal to, or greater than the specified high score + */ + @Override + public int compareTo(HighScore other) { + return Integer.compare(this.numberOfGuesses, other.numberOfGuesses); + } + + @Override + public String toString() { + return username + ": " + numberOfGuesses + " guess" + (numberOfGuesses == 1 ? "" : "es"); + } +} diff --git a/app/src/main/java/io/github/project516/NumberGuessingGame/HighScoreManager.java b/app/src/main/java/io/github/project516/NumberGuessingGame/HighScoreManager.java new file mode 100644 index 0000000..4121a35 --- /dev/null +++ b/app/src/main/java/io/github/project516/NumberGuessingGame/HighScoreManager.java @@ -0,0 +1,162 @@ +package io.github.project516.NumberGuessingGame; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +/** + * Manages high scores for the Number Guessing Game. Handles loading, saving, and retrieving high + * scores from persistent storage. + */ +public class HighScoreManager { + private static final String CONFIG_DIR = ".numberguessinggame"; + private static final String HIGH_SCORE_FILE = "highscores.properties"; + private static final int MAX_HIGH_SCORES = 10; + + private final File highScoreFile; + private final List highScores; + + /** + * Constructs a new HighScoreManager and loads existing high scores from storage. + * + * @throws IOException if there is an error creating the storage directory + */ + public HighScoreManager() throws IOException { + this(CONFIG_DIR); + } + + /** + * Constructs a new HighScoreManager with a custom config directory. Used primarily for testing. + * + * @param configDirName the name of the config directory + * @throws IOException if there is an error creating the storage directory + */ + HighScoreManager(String configDirName) throws IOException { + File configDir = new File(System.getProperty("user.home"), configDirName); + if (!configDir.exists()) { + if (!configDir.mkdirs()) { + throw new IOException("Failed to create config directory: " + configDir); + } + } + + highScoreFile = new File(configDir, HIGH_SCORE_FILE); + highScores = new ArrayList<>(); + loadHighScores(); + } + + /** + * Loads high scores from the properties file. If the file doesn't exist or can't be read, an + * empty high score list is initialized. + */ + private void loadHighScores() { + if (!highScoreFile.exists()) { + return; + } + + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(highScoreFile)) { + props.load(fis); + + for (String key : props.stringPropertyNames()) { + if (key.startsWith("score.")) { + try { + String[] parts = props.getProperty(key).split(":"); + if (parts.length == 2) { + String username = parts[0]; + int guesses = Integer.parseInt(parts[1]); + highScores.add(new HighScore(username, guesses)); + } + } catch (Exception e) { + // Skip invalid entries + } + } + } + Collections.sort(highScores); + } catch (IOException e) { + // If we can't read the file, just start with empty high scores + } + } + + /** + * Saves the current high scores to the properties file. + * + * @throws IOException if there is an error writing to the file + */ + private void saveHighScores() throws IOException { + Properties props = new Properties(); + + for (int i = 0; i < highScores.size(); i++) { + HighScore score = highScores.get(i); + props.setProperty("score." + i, score.getUsername() + ":" + score.getNumberOfGuesses()); + } + + try (FileOutputStream fos = new FileOutputStream(highScoreFile)) { + props.store(fos, "Number Guessing Game High Scores"); + } + } + + /** + * Adds a new high score. The score will only be saved if it is in the top scores. The list is + * automatically sorted and trimmed to the maximum number of high scores. + * + * @param username the username of the player + * @param numberOfGuesses the number of guesses it took to win + * @return true if the score was added to the high scores list, false otherwise + */ + public boolean addHighScore(String username, int numberOfGuesses) { + try { + HighScore newScore = new HighScore(username, numberOfGuesses); + highScores.add(newScore); + Collections.sort(highScores); + + // Keep only top scores + if (highScores.size() > MAX_HIGH_SCORES) { + highScores.subList(MAX_HIGH_SCORES, highScores.size()).clear(); + } + + saveHighScores(); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Gets the top high scores. + * + * @param limit the maximum number of high scores to return + * @return a list of the top high scores, limited by the specified amount + */ + public List getTopHighScores(int limit) { + int count = Math.min(limit, highScores.size()); + return new ArrayList<>(highScores.subList(0, count)); + } + + /** + * Gets all high scores. + * + * @return a list of all high scores + */ + public List getAllHighScores() { + return new ArrayList<>(highScores); + } + + /** + * Checks if a given number of guesses qualifies as a high score. + * + * @param numberOfGuesses the number of guesses to check + * @return true if this would be a high score, false otherwise + */ + public boolean isHighScore(int numberOfGuesses) { + if (highScores.size() < MAX_HIGH_SCORES) { + return true; + } + HighScore lowestHighScore = highScores.get(highScores.size() - 1); + return numberOfGuesses < lowestHighScore.getNumberOfGuesses(); + } +} diff --git a/app/src/main/java/io/github/project516/NumberGuessingGame/Username.java b/app/src/main/java/io/github/project516/NumberGuessingGame/Username.java index df078c3..f74c530 100644 --- a/app/src/main/java/io/github/project516/NumberGuessingGame/Username.java +++ b/app/src/main/java/io/github/project516/NumberGuessingGame/Username.java @@ -1,9 +1,51 @@ package io.github.project516.NumberGuessingGame; /** - * Placeholder class for username functionality. Will be used for a high score system in future - * versions. + * Utility class for prompting and validating usernames in the Number Guessing Game. Used for the + * high score system. */ public class Username { - // TODO - add usernames for a high score system + private static final int MAX_USERNAME_LENGTH = 20; + private static final int MIN_USERNAME_LENGTH = 1; + + /** + * Prompts the user for a username using the console. + * + * @param scan the ScannerHelper instance to use for user input + * @return the validated username + */ + public static String promptUsername(ScannerHelper scan) { + // Clear any remaining newline from previous nextInt() call + scan.scan.nextLine(); + + while (true) { + System.out.print("Enter your username: "); + String username = scan.scan.nextLine().trim(); + + if (isValid(username)) { + return username; + } + + System.out.println( + "Invalid username. Must be between " + + MIN_USERNAME_LENGTH + + " and " + + MAX_USERNAME_LENGTH + + " characters."); + } + } + + /** + * Validates a username. + * + * @param username the username to validate + * @return true if the username is valid, false otherwise + */ + public static boolean isValid(String username) { + if (username == null || username.trim().isEmpty()) { + return false; + } + int length = username.trim().length(); + return length >= MIN_USERNAME_LENGTH && length <= MAX_USERNAME_LENGTH; + } } diff --git a/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreManagerTest.java b/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreManagerTest.java new file mode 100644 index 0000000..bdae0df --- /dev/null +++ b/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreManagerTest.java @@ -0,0 +1,128 @@ +package io.github.project516.NumberGuessingGame; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for the HighScoreManager class. */ +class HighScoreManagerTest { + private static final String TEST_CONFIG_DIR = ".numberguessinggame_test"; + + private void cleanup() { + // Clean up test files + File configDir = new File(System.getProperty("user.home"), TEST_CONFIG_DIR); + if (configDir.exists()) { + File[] files = configDir.listFiles(); + if (files != null) { + for (File file : files) { + file.delete(); + } + } + configDir.delete(); + } + } + + @AfterEach + void cleanupAfter() { + cleanup(); + } + + @Test + void testHighScoreManagerCreation() { + cleanup(); + assertDoesNotThrow( + () -> { + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + assertNotNull(manager); + }); + } + + @Test + void testAddHighScore() throws IOException { + cleanup(); + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + assertTrue(manager.addHighScore("Alice", 5)); + + List scores = manager.getAllHighScores(); + assertEquals(1, scores.size()); + assertEquals("Alice", scores.get(0).getUsername()); + assertEquals(5, scores.get(0).getNumberOfGuesses()); + } + + @Test + void testHighScoresAreSorted() throws IOException { + cleanup(); + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + manager.addHighScore("Charlie", 7); + manager.addHighScore("Alice", 3); + manager.addHighScore("Bob", 5); + + List scores = manager.getAllHighScores(); + assertEquals(3, scores.size()); + assertEquals("Alice", scores.get(0).getUsername()); + assertEquals("Bob", scores.get(1).getUsername()); + assertEquals("Charlie", scores.get(2).getUsername()); + } + + @Test + void testTopHighScoresLimit() throws IOException { + cleanup(); + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + + // Add 15 scores + for (int i = 1; i <= 15; i++) { + manager.addHighScore("Player" + i, i); + } + + // Should only keep top 10 + List allScores = manager.getAllHighScores(); + assertEquals(10, allScores.size()); + assertEquals("Player1", allScores.get(0).getUsername()); + assertEquals("Player10", allScores.get(9).getUsername()); + } + + @Test + void testGetTopHighScores() throws IOException { + cleanup(); + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + + for (int i = 1; i <= 10; i++) { + manager.addHighScore("Player" + i, i); + } + + List top5 = manager.getTopHighScores(5); + assertEquals(5, top5.size()); + assertEquals("Player1", top5.get(0).getUsername()); + assertEquals("Player5", top5.get(4).getUsername()); + } + + @Test + void testIsHighScore() throws IOException { + cleanup(); + HighScoreManager manager = new HighScoreManager(TEST_CONFIG_DIR); + + // Empty list - everything is a high score + assertTrue(manager.isHighScore(100)); + + // Add 10 scores + for (int i = 1; i <= 10; i++) { + manager.addHighScore("Player" + i, i * 10); + } + + // 5 is better than all existing scores + assertTrue(manager.isHighScore(5)); + + // 50 is better than some existing scores + assertTrue(manager.isHighScore(50)); + + // 100 is equal to worst score - not better + assertFalse(manager.isHighScore(100)); + + // 150 is worse than all existing scores + assertFalse(manager.isHighScore(150)); + } +} diff --git a/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreTest.java b/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreTest.java new file mode 100644 index 0000000..726d6c3 --- /dev/null +++ b/app/src/test/java/io/github/project516/NumberGuessingGame/HighScoreTest.java @@ -0,0 +1,83 @@ +package io.github.project516.NumberGuessingGame; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** Unit tests for the HighScore class. */ +class HighScoreTest { + @Test + void testHighScoreCreation() { + HighScore score = new HighScore("Alice", 5); + assertEquals("Alice", score.getUsername()); + assertEquals(5, score.getNumberOfGuesses()); + } + + @Test + void testHighScoreWithWhitespace() { + HighScore score = new HighScore(" Bob ", 3); + assertEquals("Bob", score.getUsername()); + assertEquals(3, score.getNumberOfGuesses()); + } + + @Test + void testHighScoreNullUsername() { + assertThrows( + IllegalArgumentException.class, + () -> { + new HighScore(null, 5); + }); + } + + @Test + void testHighScoreEmptyUsername() { + assertThrows( + IllegalArgumentException.class, + () -> { + new HighScore("", 5); + }); + } + + @Test + void testHighScoreWhitespaceOnlyUsername() { + assertThrows( + IllegalArgumentException.class, + () -> { + new HighScore(" ", 5); + }); + } + + @Test + void testHighScoreInvalidGuessCount() { + assertThrows( + IllegalArgumentException.class, + () -> { + new HighScore("Alice", 0); + }); + assertThrows( + IllegalArgumentException.class, + () -> { + new HighScore("Alice", -1); + }); + } + + @Test + void testHighScoreComparison() { + HighScore score1 = new HighScore("Alice", 5); + HighScore score2 = new HighScore("Bob", 3); + HighScore score3 = new HighScore("Charlie", 7); + + assertTrue(score2.compareTo(score1) < 0); // Bob's 3 < Alice's 5 + assertTrue(score1.compareTo(score3) < 0); // Alice's 5 < Charlie's 7 + assertTrue(score3.compareTo(score2) > 0); // Charlie's 7 > Bob's 3 + } + + @Test + void testHighScoreToString() { + HighScore score1 = new HighScore("Alice", 1); + HighScore score2 = new HighScore("Bob", 5); + + assertEquals("Alice: 1 guess", score1.toString()); + assertEquals("Bob: 5 guesses", score2.toString()); + } +} diff --git a/app/src/test/java/io/github/project516/NumberGuessingGame/UsernameTest.java b/app/src/test/java/io/github/project516/NumberGuessingGame/UsernameTest.java new file mode 100644 index 0000000..590d1e2 --- /dev/null +++ b/app/src/test/java/io/github/project516/NumberGuessingGame/UsernameTest.java @@ -0,0 +1,29 @@ +package io.github.project516.NumberGuessingGame; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** Unit tests for the Username class. */ +class UsernameTest { + @Test + void testValidUsername() { + assertTrue(Username.isValid("Alice")); + assertTrue(Username.isValid("Bob123")); + assertTrue(Username.isValid("a")); // 1 character + assertTrue(Username.isValid("12345678901234567890")); // 20 characters + } + + @Test + void testInvalidUsername() { + assertFalse(Username.isValid(null)); + assertFalse(Username.isValid("")); + assertFalse(Username.isValid(" ")); + assertFalse(Username.isValid("123456789012345678901")); // 21 characters - too long + } + + @Test + void testUsernameWithWhitespace() { + assertTrue(Username.isValid(" Alice ")); // Whitespace trimmed, valid + } +}