diff --git a/.gitignore b/.gitignore index 9bf95a9..b68b82e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ build/ ### Mac OS ### .DS_Store + +shell.nix diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ed34fc0..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "interactive", - "java.format.settings.profile": "GoogleStyle", - "editor.formatOnSave": true, - "editor.defaultFormatter": "redhat.java", -} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c8724c --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +Millions stock trading game + +Made by: +- Nikolai Oliver Aasheim Lydvo +- Martin Olai Amundsen Henøen + +How to run the game: +1. Download the latest release +2. Ensure you have Java 25 and the Maven commandline tool installed +3. Navigate to the project root directory and run the command: "mvn javafx:run" +4. Set up your game configuration and start game diff --git a/pom.xml b/pom.xml index 5a5267a..3a9450d 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ org.junit.jupiter junit-jupiter - 6.0.1 + 5.11.0 test @@ -48,7 +48,7 @@ javafx-maven-plugin 0.0.8 - temppackage.Main + millions.App diff --git a/src/main/java/millions/App.java b/src/main/java/millions/App.java new file mode 100644 index 0000000..ed5d7c7 --- /dev/null +++ b/src/main/java/millions/App.java @@ -0,0 +1,95 @@ +package millions; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.stage.Stage; +import millions.controller.GameController; +import millions.controller.fileIO.InvalidFormatException; +import millions.controller.fileIO.UncheckedFileNotFoundException; +import millions.view.ExitView; +import millions.view.GameView; +import millions.view.StartView; + +/** Main JavaFX application entry point for the Millions stock trading game. */ +public class App extends Application { + private static final Logger logger = Logger.getLogger(App.class.getName()); + private static final String STYLESHEET = + Objects.requireNonNull(App.class.getResource("/styles/millions.css")).toExternalForm(); + + @Override + public void start(Stage stage) { + GameController controller = new GameController(); + StartView startView = new StartView(stage); + + startView + .getStartButton() + .setOnAction( + event -> { + try { + controller.startGame( + startView.getName(), + new BigDecimal(startView.getStartingAmount()), + startView.getSelectedFile().toPath(), + startView.getPreRunWeeks()); + + GameView gameView = + new GameView( + controller, + () -> { + ExitView exitView = new ExitView(controller.getPlayer()); + exitView.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + Scene exitScene = new Scene(exitView, 500, 400); + exitScene.getStylesheets().add(STYLESHEET); + stage.setScene(exitScene); + }); + controller.getPlayer().addListener(gameView); + controller.getExchange().addListener(gameView); + + Scene gameScene = new Scene(gameView, 1920, 1080); + gameScene.getStylesheets().add(STYLESHEET); + stage.setScene(gameScene); + } catch (InvalidFormatException e) { + logger.log(Level.WARNING, "InvalidFormatException: " + e.getMessage()); + + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Error with selected file"); + alert.setContentText( + e.getMessage() + "\nPlease control the format of the selected file"); + + alert.showAndWait(); + + } catch (UncheckedFileNotFoundException e) { + logger.log(Level.WARNING, "FileNotFoundException: " + e.getMessage()); + + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Error"); + alert.setHeaderText("Error with selected file"); + alert.setContentText(e.getMessage()); + + alert.showAndWait(); + + } catch (RuntimeException ex) { + logger.log(Level.SEVERE, ex.getMessage()); + System.exit(0); + } + }); + + Scene scene = new Scene(startView, 400, 350); + scene.getStylesheets().add(STYLESHEET); + stage.setTitle("Millions"); + stage.setMinWidth(900); + stage.setMinHeight(600); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/millions/Exchange.java b/src/main/java/millions/Exchange.java deleted file mode 100644 index 98584f2..0000000 --- a/src/main/java/millions/Exchange.java +++ /dev/null @@ -1,68 +0,0 @@ -package millions; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Random; - -public class Exchange { - private String name; - private Map stocks; - private int weekNumber; - private Random random = new Random(); - - public Exchange(String name, List stockList) { - this.name = name; - this.stocks = new HashMap<>(); - this.weekNumber = 1; - - // Populate the stocks map to get ticker -> stock - for (Stock stock : stockList) { - this.stocks.put(stock.getSymbol(), stock); - } - } - - public void buy(Player player, Stock stock, BigDecimal quantity) { - Share shareToBuy = new Share(stock, quantity, stock.getSalesPrice()); - Purchase purchase = new Purchase(shareToBuy, this.weekNumber); - purchase.commit(player); - } - - public void sell(Player player, Share share) { - Sale sale = new Sale(share, weekNumber); - sale.commit(player); - } - - public Map getStocks() { - return this.stocks; - } - - public Stock getStock(String symbol) { - return this.stocks.get(symbol); - } - - public boolean hasStock(String symbol) { - return this.stocks.containsKey(symbol); - } - - public List findStocks(String searchTerm) { - return this.stocks.values().stream() - .filter(s -> s.getSymbol().contains(searchTerm) || s.getCompany().contains(searchTerm)) - .toList(); - } - - public void advance() { - this.weekNumber++; - for (Stock stock : this.stocks.values()) { - double change = 0.9 + random.nextDouble() * 0.2; - stock.addNewSalesPrice( - stock - .getSalesPrice() - .multiply(BigDecimal.valueOf(change)) - .setScale(2, RoundingMode.HALF_UP)); - // RoundingMode from AI suggestion - } - } -} diff --git a/src/main/java/millions/Player.java b/src/main/java/millions/Player.java deleted file mode 100644 index 747c8d0..0000000 --- a/src/main/java/millions/Player.java +++ /dev/null @@ -1,43 +0,0 @@ -package millions; - -import java.math.BigDecimal; - -public class Player { - private String name; - private BigDecimal startingMoney; - private BigDecimal money; - private Portfolio portfolio; - private TransactionArchive transactionArchive; - - public Player(String name, BigDecimal startingMoney) { - this.name = name; - this.startingMoney = startingMoney; - this.money = startingMoney; - this.portfolio = new Portfolio(); - this.transactionArchive = new TransactionArchive(); - } - - public void addMoney(BigDecimal amount) { - this.money = this.money.add(amount); - } - - public void withdrawMoney(BigDecimal amount) { - this.money = this.money.subtract(amount); - } - - public String getName() { - return this.name; - } - - public BigDecimal getMoney() { - return this.money; - } - - public Portfolio getPortfolio() { - return this.portfolio; - } - - public TransactionArchive getTransactionArchive() { - return this.transactionArchive; - } -} diff --git a/src/main/java/millions/Portfolio.java b/src/main/java/millions/Portfolio.java deleted file mode 100644 index e6fe889..0000000 --- a/src/main/java/millions/Portfolio.java +++ /dev/null @@ -1,34 +0,0 @@ -package millions; - -import java.util.ArrayList; -import java.util.List; - -public class Portfolio { - List shares; - - public Portfolio() { - shares = new ArrayList<>(); - } - - public boolean addShare(Share share) { - return this.shares.add(share); - } - - public boolean removeShare(Share share) { - return this.shares.remove(share); - } - - public List getShares() { - return this.shares; - } - - public List getShares(String symbol) { - return this.shares.stream() - .filter(share -> share.getStock().getSymbol().equals(symbol)) - .toList(); - } - - public boolean contains(Share share) { - return this.shares.contains(share); - } -} diff --git a/src/main/java/millions/Share.java b/src/main/java/millions/Share.java deleted file mode 100644 index 6eddada..0000000 --- a/src/main/java/millions/Share.java +++ /dev/null @@ -1,27 +0,0 @@ -package millions; - -import java.math.BigDecimal; - -public class Share { - Stock stock; - BigDecimal quantity; - BigDecimal purchasePrice; - - public Share(Stock stock, BigDecimal quantity, BigDecimal purchasePrice) { - this.stock = stock; - this.quantity = quantity; - this.purchasePrice = purchasePrice; - } - - public Stock getStock() { - return this.stock; - } - - public BigDecimal getQuantity() { - return this.quantity; - } - - public BigDecimal getPurchasePrice() { - return this.purchasePrice; - } -} diff --git a/src/main/java/millions/Stock.java b/src/main/java/millions/Stock.java deleted file mode 100644 index 886ecba..0000000 --- a/src/main/java/millions/Stock.java +++ /dev/null @@ -1,32 +0,0 @@ -package millions; - -import java.math.BigDecimal; -import java.util.List; - -public class Stock { - String symbol; - String company; - List prices; - - public Stock(String symbol, String company, List prices) { - this.symbol = symbol; - this.company = company; - this.prices = prices; - } - - public String getSymbol() { - return this.symbol; - } - - public String getCompany() { - return this.company; - } - - public BigDecimal getSalesPrice() { - return this.prices.getLast(); - } - - public void addNewSalesPrice(BigDecimal price) { - this.prices.add(price); - } -} diff --git a/src/main/java/millions/controller/GameController.java b/src/main/java/millions/controller/GameController.java new file mode 100644 index 0000000..17157dc --- /dev/null +++ b/src/main/java/millions/controller/GameController.java @@ -0,0 +1,170 @@ +package millions.controller; + +import java.io.FileNotFoundException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import millions.controller.fileIO.CSV.CSVFileHandler; +import millions.controller.fileIO.InvalidFormatException; +import millions.controller.fileIO.UncheckedFileNotFoundException; +import millions.model.Exchange; +import millions.model.Player; +import millions.model.Share; +import millions.model.Stock; +import millions.model.Transaction; +import millions.model.calculators.PurchaseCalculator; + +/** Controls game initialization. */ +public class GameController { + private Player player; + private Exchange exchange; + + public void startGame( + String name, BigDecimal startingMoney, Path stockFilePath, int preRunWeeks) { + if (preRunWeeks < 0) { + throw new IllegalArgumentException("Pre run weeks cannot be negative"); + } + try { + CSVFileHandler fileHandler = new CSVFileHandler(); + List stocks = fileHandler.getStocksFromFile(stockFilePath); + + exchange = new Exchange("Exchange", stocks); + for (int i = 0; i < preRunWeeks; i++) { + exchange.advance(); + } + + player = new Player(name, startingMoney); + + } catch (FileNotFoundException e) { + throw new UncheckedFileNotFoundException(e.getMessage()); + } catch (InvalidFormatException e) { + throw new InvalidFormatException(e.getMessage()); + } + } + + public Player getPlayer() { + return player; + } + + public Exchange getExchange() { + return exchange; + } + + public List getStocks() { + return exchange.getStocks().values().stream() + .sorted(Comparator.comparing(Stock::getSymbol)) + .collect(Collectors.toList()); + } + + /** + * Gives alphabetic sort of findStocks + * + * @param searchTerm + * @return Alphabetically sorted list of stocks + */ + public List searchStocks(String searchTerm) { + if (searchTerm == null || searchTerm.isBlank()) { + return getStocks(); + } + return exchange.findStocks(searchTerm).stream() + .sorted(Comparator.comparing(Stock::getSymbol)) + .collect(Collectors.toList()); + } + + /** + * Get stocks with symbol + * + * @param symbol + * @return stocks with matching symbol + */ + public Stock getStock(String symbol) { + return exchange.getStock(symbol); + } + + /** + * Purchases a stock + * + * @param symbol Symbol of desired stock + * @param quantity How much of a stock to purchase + * @return Transaction object for purchase + */ + public Transaction buyStock(String symbol, BigDecimal quantity) { + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + return exchange.buy(symbol, player, quantity); + } + + /** + * Sells a share + * + * @param share Share to sell + * @return Transaction object for sale + */ + public Transaction sellShare(Share share) { + if (share == null) { + throw new IllegalArgumentException("Share cannot be null"); + } + return exchange.sell(share, player); + } + + /** + * Returns a players owned shares + * + * @param symbol Symbol for stock + * @return List of shares + */ + public List getOwnedShares(String symbol) { + return new ArrayList<>(player.getPortfolio().getShares(symbol)); + } + + public void advanceWeek() { + exchange.advance(); + } + + /** + * Returns the players owned quantity of a given share + * + * @param symbol Symbol for shares + * @return BigDecimal Quantity + */ + public BigDecimal getOwnedQuantity(String symbol) { + return player.getPortfolio().getShares(symbol).stream() + .map(Share::getQuantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * Returns the maximum quantity of a stock the player can purchase + * + * @param symbol Symbol for stock + * @return int Quantity + */ + public int getMaxBuyableQuantity(String symbol) { + Stock stock = getStock(symbol); + if (stock == null || player == null) { + return 0; + } + + BigDecimal money = player.getMoney(); + BigDecimal price = stock.getSalesPrice(); + if (price.compareTo(BigDecimal.ZERO) <= 0) { + return 0; + } + + int upperBound = money.divide(price, 0, RoundingMode.FLOOR).intValue(); + // Loop so we take care of comission/tax stuff + for (int quantity = upperBound; quantity >= 1; quantity--) { + Share share = new Share(stock, quantity, price); + BigDecimal total = new PurchaseCalculator(share).calculateTotal(); + if (total.compareTo(money) <= 0) { + return quantity; + } + } + return 0; + } +} diff --git a/src/main/java/millions/controller/fileIO/CSV/CSVFileHandler.java b/src/main/java/millions/controller/fileIO/CSV/CSVFileHandler.java new file mode 100644 index 0000000..051e1f0 --- /dev/null +++ b/src/main/java/millions/controller/fileIO/CSV/CSVFileHandler.java @@ -0,0 +1,46 @@ +package millions.controller.fileIO.CSV; + +import millions.controller.fileIO.InvalidFormatException; +import millions.controller.fileIO.StockFileReader; +import millions.controller.fileIO.UncheckedFileNotFoundException; +import millions.model.Stock; + + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.util.List; + +/** + * Bundles StockFileReader and CSVStockFileParser together to reduce boilerplate code when reading stocks from a csv file + */ +public class CSVFileHandler { + StockFileReader reader; + CSVStockFileParser parser; + + public CSVFileHandler() { + this.reader = new StockFileReader(); + this.parser = new CSVStockFileParser(); + } + + /** + * Reads and parses stocks from a csv file + * @param filePath Path to stock file + * @return list of stock objects created from parsed file + * @throws InvalidFormatException Throws an InvalidFormatException received from parser + * @throws UncheckedFileNotFoundException Upon Receiving a FilenotFoundException + */ + public List getStocksFromFile(Path filePath) throws FileNotFoundException { + try { + StockFileReader reader = new StockFileReader(); + List lines = reader.readFile(filePath); + + CSVStockFileParser parser = new CSVStockFileParser(); + return parser.parse(lines); + + } catch (InvalidFormatException e) { + throw new InvalidFormatException(e.getMessage()); + } catch (FileNotFoundException e) { + throw new FileNotFoundException(e.getMessage()); + } + } +} diff --git a/src/main/java/millions/controller/fileIO/CSV/CSVStockFileParser.java b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileParser.java new file mode 100644 index 0000000..caf1097 --- /dev/null +++ b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileParser.java @@ -0,0 +1,69 @@ +package millions.controller.fileIO.CSV; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import millions.controller.fileIO.InvalidFormatException; +import millions.model.Stock; + +/** Parses CSV lines into Stock objects. */ +public class CSVStockFileParser { + + public CSVStockFileParser() {} + + /** + * Verifies the amount of data fields present in supplied CSV file. + * + * @param lines Lines from csv file + * @return Boolean: True if file satisfies expected format + */ + public boolean verifyCSV(List lines) { + return lines.stream() + .filter(l -> !(l.startsWith("#") || l.isBlank())) + .noneMatch(l -> l.split(",").length != 4 || l.split(",")[3].split(";").length != 6); + } + + /** + * Parses the supplied lines if they satisfy the correct format expectations + * + * @param lines

lines to be parsed.
+ * Each line must contain four data fields: String,String,BigDecimal,String
+ * (Fields cannot be blank)
+ * blank lines or lines beginning with '#' are ignored + *

+ * @return List of stock objects created from the supplied lines + * @throws InvalidFormatException If one or more lines contain too many or too few data fields + * @throws InvalidFormatException If the BigDecimal field on one or more lines are not compatible + * @throws InvalidFormatException Upon recieving an IllegalArgumentException (Either Symbol or Company name is blank) + */ + public List parse(List lines) { + List stocks = new ArrayList<>(); + if (verifyCSV(lines)) { + lines.stream() + .filter(l -> !((l.startsWith("#") || l.isBlank()))) + .forEach( + l -> { + try { + String[] split = l.split(","); + String symbol = split[0]; + String company = split[1]; + BigDecimal price = new BigDecimal(split[2]); + String[] functionValues = split[3].split(";"); + List convertedFunctionValues = new ArrayList<>(); + for (String functionValue : functionValues) { + convertedFunctionValues.add(new BigDecimal(functionValue)); + } + stocks.add(new Stock(symbol, company, price, convertedFunctionValues)); + } catch (NumberFormatException e) { + throw new InvalidFormatException("Error with number conversion on line: " + l + "\n" + "ensure all number fields are actually numbers"); + } catch (IllegalArgumentException e) { + throw new InvalidFormatException("Illegal argument on line: " + l + "\n" + e.getMessage()); + } + }); + } else { + throw new InvalidFormatException("Incorrect format for CSV File: incorrect amount of data fields detected on one or more lines"); + } + return stocks; + } +} diff --git a/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java new file mode 100644 index 0000000..fdce35d --- /dev/null +++ b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java @@ -0,0 +1,56 @@ +package millions.controller.fileIO.CSV; + +import java.io.*; +import java.io.BufferedWriter; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.Logger; + +import millions.controller.fileIO.StockFileWriter; +import millions.model.Stock; + +/** + * Implements StockFileWriter.
+ * Converts a list of stock objects into a writeable string with a CSV format. + * + */ +public class CSVStockFileWriter implements StockFileWriter { + private static final Logger logger = Logger.getLogger(CSVStockFileWriter.class.getName()); + private String finalString; + + public CSVStockFileWriter() {} + + /** + * Formats given string to CSV format to prepare for writing to file + */ + @Override + public String formatString(List stocks) { + StringBuilder builder = new StringBuilder(); + stocks.forEach(stock -> { + builder.append(stock.getSymbol()); + builder.append(","); + builder.append(stock.getCompany()); + builder.append(","); + builder.append(stock.getSalesPrice().toString()); + builder.append("\n"); + }); + return builder.toString(); + } + + /** + * Writes the saved string to a file + * @param stocks List of stock objects to write + * @param path Path to desired file + * @return Boolean for success + */ + @Override + public boolean write(List stocks, Path path){ + try (FileWriter fw = new FileWriter(path.toString()); BufferedWriter writer = new BufferedWriter(fw);) { + this.formatString(stocks); + writer.write(finalString); + } catch (IOException e) { + logger.severe(e.getMessage()); + } + return false; + } +} diff --git a/src/main/java/millions/controller/fileIO/InvalidFormatException.java b/src/main/java/millions/controller/fileIO/InvalidFormatException.java new file mode 100644 index 0000000..a1e2136 --- /dev/null +++ b/src/main/java/millions/controller/fileIO/InvalidFormatException.java @@ -0,0 +1,10 @@ +package millions.controller.fileIO; + +/** + * Exception to be thrown when verifying the format of files + */ +public class InvalidFormatException extends RuntimeException { + public InvalidFormatException(String message) { + super(message); + } +} diff --git a/src/main/java/millions/controller/fileIO/StockFileReader.java b/src/main/java/millions/controller/fileIO/StockFileReader.java new file mode 100644 index 0000000..6c8069d --- /dev/null +++ b/src/main/java/millions/controller/fileIO/StockFileReader.java @@ -0,0 +1,45 @@ +package millions.controller.fileIO; + + + +import java.io.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + + +/** + * Reads a file and returns its lines as a list of strings. + */ +public class StockFileReader { + private static final Logger logger = Logger.getLogger(StockFileReader.class.getName()); + public StockFileReader() {} + + /** + * Reads the file found at the specified path + * @param path Path to the desired file + * @return List of each line in the file as a string + * @throws UncheckedFileNotFoundException Upon encountering a FileNotFoundException + */ + public List readFile(Path path) throws FileNotFoundException { + File file = new File(path.toString()); + List lines = new ArrayList<>(); + try (Reader reader = new FileReader(file); + BufferedReader bufferedReader = new BufferedReader(reader)) { + String line; + while ((line = bufferedReader.readLine()) != null) { + lines.add(line); + } + } catch (IOException e) { + if (e instanceof FileNotFoundException) { + throw new FileNotFoundException("Couldn't find file at specified path"); + } + else { + logger.log(Level.SEVERE, "Encountered unexpected IOException: ", e.getMessage()); + } + } + return lines; + } +} \ No newline at end of file diff --git a/src/main/java/millions/controller/fileIO/StockFileWriter.java b/src/main/java/millions/controller/fileIO/StockFileWriter.java new file mode 100644 index 0000000..a85f363 --- /dev/null +++ b/src/main/java/millions/controller/fileIO/StockFileWriter.java @@ -0,0 +1,14 @@ +package millions.controller.fileIO; + +import millions.model.Stock; + +import java.nio.file.Path; +import java.util.List; + +/** + * Interface for writing stock data to a file. + */ +public interface StockFileWriter { + String formatString(List stocks); + boolean write(List stocks, Path path); +} diff --git a/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java b/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java new file mode 100644 index 0000000..6f87d4c --- /dev/null +++ b/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java @@ -0,0 +1,10 @@ +package millions.controller.fileIO; + +/** + * RuntimeException wrapper for FileNotFoundException. + */ +public class UncheckedFileNotFoundException extends RuntimeException { + public UncheckedFileNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/millions/model/Exchange.java b/src/main/java/millions/model/Exchange.java new file mode 100644 index 0000000..fbbec8d --- /dev/null +++ b/src/main/java/millions/model/Exchange.java @@ -0,0 +1,183 @@ +package millions.model; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.Collectors; +import millions.model.calculators.PriceChangeCalculator; +import millions.model.factories.PurchaseFactory; +import millions.model.factories.SaleFactory; +import millions.model.factories.TransactionFactory; + +/** + * The stock exchange where players buy and sell shares. Manages stocks and simulates weekly price + * changes. + */ +public class Exchange { + private String name; + private Map stocks; + private int weekNumber; + private Random random = new Random(); + private final TransactionFactory purchaseFactory = new PurchaseFactory(); + private final TransactionFactory saleFactory = new SaleFactory(); + private final List listeners = new ArrayList<>(); + + public Exchange(String name, List stockList) { + this.name = name; + this.stocks = new HashMap<>(); + this.weekNumber = 1; + + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Exchange name cannot be null or blank"); + } + + // Populate the stocks map to get ticker -> stock + for (Stock stock : stockList) { + this.stocks.put(stock.getSymbol(), stock); + } + } + + /** + * Purchases a quantity of a given stock. + * + * @param symbol Symbol identifying the stock. + * @param player The player doing the purchase. + * @param quantity Quantity to purchase. + * @throws IllegalArgumentException If a stock isn't found + * @return Transaction object for purchase. + */ + public Transaction buy(String symbol, Player player, BigDecimal quantity) { + + Stock stock = this.stocks.get(symbol); + if (stock == null) { + throw new IllegalArgumentException("Stock not found"); + } + + Share shareToBuy = new Share(stock, quantity, stock.getSalesPrice()); + + Transaction purchase = purchaseFactory.createTransaction(shareToBuy, weekNumber); + purchase.commit(player); + notifyTransactionCompleted(purchase); + + return purchase; + } + + public Transaction buy(String symbol, Player player, int quantity) { + return this.buy(symbol, player, BigDecimal.valueOf(quantity)); + } + + /** + * Creates a transaction for a sale. + * + * @param share share to sell. + * @param player player performing the sale. + * @return Transaction object for sale. + */ + public Transaction sell(Share share, Player player) { + Transaction sale = saleFactory.createTransaction(share, weekNumber); + + sale.commit(player); + notifyTransactionCompleted(sale); + return sale; + } + + public String getName() { + return this.name; + } + + public int getWeekNumber() { + return this.weekNumber; + } + + public Map getStocks() { + return this.stocks; + } + + public Stock getStock(String symbol) { + return this.stocks.get(symbol); + } + + public boolean hasStock(String symbol) { + return this.stocks.containsKey(symbol); + } + + public List findStocks(String searchTerm) { + return this.stocks.values().stream() + .filter(s -> s.getSymbol().contains(searchTerm) || s.getCompany().contains(searchTerm)) + .toList(); + } + + /** + * Returns the best performing stocks. + * + * @param limit Mmount of stocks to collect. + * @return Sorted list of stock objects sorted + */ + public List getGainers(int limit) { + Collection stocksCollection = stocks.values(); + return stocksCollection.stream() + .sorted(Comparator.comparing(Stock::getLatestPriceChange).reversed()) + .limit(limit) + .collect(Collectors.toList()); + } + + /** + * Returns the worst performing stocks. + * + * @param limit Amount of stocks to collect + * @return Sorted list of stock objects + */ + public List getLosers(int limit) { + Collection stocksCollection = stocks.values(); + return stocksCollection.stream() + .sorted(Comparator.comparing(Stock::getLatestPriceChange)) + .limit(limit) + .collect(Collectors.toList()); + } + + /** Advances the current game week by performing new price calculations for all stocks. */ + public void advance() { + PriceChangeCalculator priceChangeCalculator = new PriceChangeCalculator(); + this.weekNumber++; + for (Stock stock : this.stocks.values()) { + BigDecimal change = priceChangeCalculator.calculateChange(stock); + stock.addNewSalesPrice(stock.getSalesPrice().add(change).setScale(2, RoundingMode.HALF_UP)); + // Round to stop crazy values + + // double change = 0.9 + random.nextDouble() * 0.2; + // stock.addNewSalesPrice( + // stock + // .getSalesPrice() + // .multiply(BigDecimal.valueOf(change)) + // .setScale(2, RoundingMode.HALF_UP)); + // // RoundingMode from AI suggestion + } + notifyWeekAdvanced(); + } + + public void addListener(ExchangeListener listener) { + listeners.add(listener); + } + + public void removeListener(ExchangeListener listener) { + listeners.remove(listener); + } + + private void notifyWeekAdvanced() { + for (ExchangeListener listener : listeners) { + listener.onWeekAdvanced(weekNumber); + } + } + + private void notifyTransactionCompleted(Transaction transaction) { + for (ExchangeListener listener : listeners) { + listener.onTransactionCompleted(transaction); + } + } +} diff --git a/src/main/java/millions/model/ExchangeListener.java b/src/main/java/millions/model/ExchangeListener.java new file mode 100644 index 0000000..6731d45 --- /dev/null +++ b/src/main/java/millions/model/ExchangeListener.java @@ -0,0 +1,11 @@ +package millions.model; + +/** + * Listener for exchange events such as week advances and completed transactions. + */ +public interface ExchangeListener { + + void onWeekAdvanced(int newWeek); + + void onTransactionCompleted(Transaction transaction); +} diff --git a/src/main/java/millions/model/Player.java b/src/main/java/millions/model/Player.java new file mode 100644 index 0000000..515926e --- /dev/null +++ b/src/main/java/millions/model/Player.java @@ -0,0 +1,175 @@ +package millions.model; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** Player class. */ +public class Player { + private String name; + private BigDecimal startingMoney; + private BigDecimal money; + private Portfolio portfolio; + private TransactionArchive transactionArchive; + // temporary attribute until a better solution is found + public int weeksTraded; + private final List listeners = new ArrayList<>(); + + /** + * @param name Name of player + * @param startingMoney Amount of money the player starts with + * @throws IllegalArgumentException + */ + public Player(String name, BigDecimal startingMoney) { + this.name = name; + this.startingMoney = startingMoney; + this.money = startingMoney; + this.portfolio = new Portfolio(); + this.transactionArchive = new TransactionArchive(); + + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Player name cannot be null or blank"); + } + + if (startingMoney == null || startingMoney.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Starting money cannot be null or negative"); + } + } + + /** + * @param amount How much money to add + * @throws IllegalArgumentException + */ + public void addMoney(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Amount cannot be null or negative"); + } + this.money = this.money.add(amount); + notifyMoneyChanged(); + } + + /** + * @param amount How much money to withdeaw + * @throws IllegalArgumentException + */ + public void withdrawMoney(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Amount cannot be null or negative"); + } + this.money = this.money.subtract(amount); + notifyMoneyChanged(); + } + + /** + * Calculates the skill level of the player + * + * @return String player status level + */ + public String getStatus() { + int weeksTraded = transactionArchive.countDistinctWeeks(); + + String status = "Novice"; + BigDecimal netWorth = getNetWorth(); + BigDecimal netWorthChange = netWorth.divide(startingMoney, RoundingMode.DOWN); + if (netWorthChange.compareTo(BigDecimal.valueOf(0.20)) >= 0 && weeksTraded >= 10) { + status = "Investor"; + } + if (netWorthChange.compareTo(BigDecimal.valueOf(0.40)) >= 0 && weeksTraded >= 20) { + status = "Speculator"; + } + return status; + } + + /** + * @return player name + */ + public String getName() { + return this.name; + } + + /** + * @return player money + */ + public BigDecimal getMoney() { + return this.money; + } + + /** + * @return player portfolio + */ + public Portfolio getPortfolio() { + return this.portfolio; + } + + /** + * @return player startingMoney + */ + public BigDecimal getStartingMoneh() { + return this.startingMoney; + } + + /** + * @param share Share to be added + */ + public void addShareToPortfolio(Share share) { + this.portfolio.addShare(share); + notifyPortfolioChanged(); + notifyStatusChanged(); + } + + /** + * @param share Share to be removed + */ + public void removeShareFromPortfolio(Share share) { + this.portfolio.removeShare(share); + notifyPortfolioChanged(); + notifyStatusChanged(); + } + + /** + * @return player net worth + */ + public BigDecimal getNetWorth() { + return this.money.add(this.portfolio.getNetWorth()); + } + + /** + * @return TransactionArchive object + */ + public TransactionArchive getTransactionArchive() { + return this.transactionArchive; + } + + /** + * @param listener PlayerListener + */ + public void addListener(PlayerListener listener) { + listeners.add(listener); + } + + /** + * @param listener PlayerListener + */ + public void removeListener(PlayerListener listener) { + listeners.remove(listener); + } + + private void notifyMoneyChanged() { + for (PlayerListener listener : listeners) { + listener.onMoneyChanged(money); + } + } + + private void notifyPortfolioChanged() { + for (PlayerListener listener : listeners) { + listener.onPortfolioChanged(); + } + } + + private void notifyStatusChanged() { + for (PlayerListener listener : listeners) { + listener.onStatusChanged(getStatus()); + } + } +} diff --git a/src/main/java/millions/model/PlayerListener.java b/src/main/java/millions/model/PlayerListener.java new file mode 100644 index 0000000..e397ead --- /dev/null +++ b/src/main/java/millions/model/PlayerListener.java @@ -0,0 +1,13 @@ +package millions.model; + +import java.math.BigDecimal; + +/** Listener for player state changes. */ +public interface PlayerListener { + + void onMoneyChanged(BigDecimal newBalance); + + void onPortfolioChanged(); + + void onStatusChanged(String newStatus); +} diff --git a/src/main/java/millions/model/Portfolio.java b/src/main/java/millions/model/Portfolio.java new file mode 100644 index 0000000..4374595 --- /dev/null +++ b/src/main/java/millions/model/Portfolio.java @@ -0,0 +1,68 @@ +package millions.model; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import millions.model.calculators.SaleCalculator; + +/** A collection of shares owned by a player. */ +public class Portfolio { + List shares; + + public Portfolio() { + shares = new ArrayList<>(); + } + + /** + * @param share Share to be added + * @return Boolean for success + */ + public boolean addShare(Share share) { + return this.shares.add(share); + } + + /** + * @param share Share to be removed + * @return Boolean for success + */ + public boolean removeShare(Share share) { + return this.shares.remove(share); + } + + /** + * @return List of shares + */ + public List getShares() { + return this.shares; + } + + /** + * @param symbol Symbol for share + * @return List of shares + */ + public List getShares(String symbol) { + return this.shares.stream() + .filter(share -> share.getStock().getSymbol().equals(symbol)) + .toList(); + } + + /** + * @return BigDecimal net worth + */ + public BigDecimal getNetWorth() { + BigDecimal total = BigDecimal.ZERO; + for (Share share : shares) { + BigDecimal value = new SaleCalculator(share).calculateTotal(); + total = total.add(value); + } + return total; + } + + /** + * @param share Share + * @return Boolean + */ + public boolean contains(Share share) { + return this.shares.contains(share); + } +} diff --git a/src/main/java/millions/Purchase.java b/src/main/java/millions/model/Purchase.java similarity index 70% rename from src/main/java/millions/Purchase.java rename to src/main/java/millions/model/Purchase.java index 72f02ad..8bbacb8 100644 --- a/src/main/java/millions/Purchase.java +++ b/src/main/java/millions/model/Purchase.java @@ -1,7 +1,10 @@ -package millions; +package millions.model; -import millions.calculators.PurchaseCalculator; +import millions.model.calculators.PurchaseCalculator; +/** + * A transaction representing the purchase of shares. + */ public class Purchase extends Transaction { public Purchase(Share share, int week) { @@ -18,7 +21,8 @@ public void commit(Player player) { throw new IllegalStateException("Not enought money"); } player.withdrawMoney(getCalculator().calculateTotal()); - player.getPortfolio().addShare(getShare()); + // Don't reach directly to the portefolio object + player.addShareToPortfolio(getShare()); player.getTransactionArchive().add(this); setCommitted(true); } diff --git a/src/main/java/millions/Sale.java b/src/main/java/millions/model/Sale.java similarity index 74% rename from src/main/java/millions/Sale.java rename to src/main/java/millions/model/Sale.java index 25f2919..1c17745 100644 --- a/src/main/java/millions/Sale.java +++ b/src/main/java/millions/model/Sale.java @@ -1,7 +1,10 @@ -package millions; +package millions.model; -import millions.calculators.SaleCalculator; +import millions.model.calculators.SaleCalculator; +/** + * A transaction representing the sale of shares. + */ public class Sale extends Transaction { public Sale(Share share, int week) { @@ -18,7 +21,7 @@ public void commit(Player player) { throw new IllegalStateException("Does not own the share"); } player.addMoney(getCalculator().calculateTotal()); - player.getPortfolio().removeShare(getShare()); + player.removeShareFromPortfolio(getShare()); player.getTransactionArchive().add(this); setCommitted(true); } diff --git a/src/main/java/millions/model/Share.java b/src/main/java/millions/model/Share.java new file mode 100644 index 0000000..5a7a726 --- /dev/null +++ b/src/main/java/millions/model/Share.java @@ -0,0 +1,60 @@ +package millions.model; + +import java.math.BigDecimal; + +/** Represents a holding of a specific stock with a quantity and purchase price. */ +public class Share { + Stock stock; + BigDecimal quantity; + BigDecimal purchasePrice; + + /** + * @param stock Which stock the share is for. + * @param quantity How many stocks. + * @param purchasePrice Purchase price of the share. + * @throws IllegalArgumentException if stock is null. + * @throws IllegalArgumentException if quantity is null. + * @throws IllegalArgumentException if purchasePrice is null. + */ + public Share(Stock stock, BigDecimal quantity, BigDecimal purchasePrice) { + this.stock = stock; + this.quantity = quantity; + this.purchasePrice = purchasePrice; + + if (stock == null) { + throw new IllegalArgumentException("Stock cannot be null"); + } + if (quantity == null) { + throw new IllegalArgumentException("Quantity cannot be null"); + } + if (purchasePrice == null) { + throw new IllegalArgumentException("Purchase price cannot be null"); + } + } + + /** Share() with int quantity. */ + public Share(Stock stock, int quantity, BigDecimal purchasePrice) { + this(stock, BigDecimal.valueOf(quantity), purchasePrice); + } + + /** + * @return Stock object. + */ + public Stock getStock() { + return this.stock; + } + + /** + * @return BigDecimal: quantity. + */ + public BigDecimal getQuantity() { + return this.quantity; + } + + /** + * @return BigDecimal PurchasePrice. + */ + public BigDecimal getPurchasePrice() { + return this.purchasePrice; + } +} diff --git a/src/main/java/millions/model/Stock.java b/src/main/java/millions/model/Stock.java new file mode 100644 index 0000000..a5445fe --- /dev/null +++ b/src/main/java/millions/model/Stock.java @@ -0,0 +1,130 @@ +package millions.model; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** Stock */ +public class Stock { + String symbol; + String company; + List volatility; + List prices; + + /** + * @param symbol Stock ticker symbol + * @param company company name + * @param prices List of prices + * @param volatilityParameters numbers used for price change calculation functions + * @throws IllegalArgumentException + */ + public Stock(String symbol, String company, List prices, List volatilityParameters) { + this.symbol = symbol; + this.company = company; + this.prices = new ArrayList<>(prices); + + this.volatility = volatilityParameters; + + if (volatilityParameters.size() != 6) { + throw new IllegalArgumentException("Invalid volatility function count"); + } + + if (symbol == null || symbol.isBlank()) { + throw new IllegalArgumentException("Symbol cannot be null or blank"); + } + + if (company == null || company.isBlank()) { + throw new IllegalArgumentException("Company cannot be null or blank"); + } + } + + + /** Stock() with single price instead of list. */ + public Stock(String symbol, String company, BigDecimal initialPrice, List volatilityFunctions) { + this(symbol, company, new ArrayList<>(List.of(initialPrice)), volatilityFunctions); + } + + /** + * @return String: symbol. + */ + public String getSymbol() { + return this.symbol; + } + + /** + * @return String: company. + */ + public String getCompany() { + return this.company; + } + + /** + * @return BigDecimal: price. + */ + public BigDecimal getSalesPrice() { + return this.prices.getLast(); + } + + /** + * @param price Sales price. + */ + public void addNewSalesPrice(BigDecimal price) { + this.prices.add(price); + } + + /** + * @return BigDecimal list of prices. + */ + public List getHistoricalPrices() { + return this.prices; + } + + /** + * @return BigDecimal highest recorded price. + */ + public BigDecimal getHighestPrice() { + BigDecimal highestPrice = this.prices.get(0); + for (BigDecimal price : this.prices) { + if (price.compareTo(highestPrice) > 0) { + highestPrice = price; + } + } + return highestPrice; + } + + /** + * @return BigDecimal lowest recorded price. + */ + public BigDecimal getLowestPrice() { + BigDecimal lowestPrice = this.prices.get(0); + for (BigDecimal price : this.prices) { + if (price.compareTo(lowestPrice) < 0) { + lowestPrice = price; + } + } + return lowestPrice; + } + + /** + * @return BigDecimal price difference from last week + */ + public BigDecimal getLatestPriceChange() { + if (this.prices.size() < 2) { + return BigDecimal.ZERO; + } + + BigDecimal currentPrice = this.prices.getLast(); + BigDecimal lastPrice = this.prices.get(this.prices.size() - 2); + + return currentPrice.subtract(lastPrice); + } + + public List getVolatilityParameters() { + return this.volatility; + } + + @Override + public String toString() { + return "Stock [symbol: " + symbol + ", company: " + company + ", prices: " + prices + "]"; + } +} diff --git a/src/main/java/millions/Transaction.java b/src/main/java/millions/model/Transaction.java similarity index 69% rename from src/main/java/millions/Transaction.java rename to src/main/java/millions/model/Transaction.java index b4b4022..69c1167 100644 --- a/src/main/java/millions/Transaction.java +++ b/src/main/java/millions/model/Transaction.java @@ -1,13 +1,14 @@ -package millions; +package millions.model; -import millions.calculators.TransactionCalculator; +import millions.model.calculators.TransactionCalculator; +/** Abstract base class for stock transactions */ public abstract class Transaction { private Share share; private int week; private TransactionCalculator transactionCalculator; - private boolean committed; + protected boolean committed; protected Transaction(Share share, int week, TransactionCalculator transactionCalculator) { this.share = share; @@ -16,18 +17,30 @@ protected Transaction(Share share, int week, TransactionCalculator transactionCa this.committed = false; } + /** + * @return Share object + */ public Share getShare() { return this.share; } + /** + * @return int: week + */ public int getWeek() { return this.week; } + /** + * @return TransactionCalculator object + */ public TransactionCalculator getCalculator() { return this.transactionCalculator; } + /** + * @return Boolean: status + */ public boolean isCommitted() { return this.committed; } diff --git a/src/main/java/millions/TransactionArchive.java b/src/main/java/millions/model/TransactionArchive.java similarity index 61% rename from src/main/java/millions/TransactionArchive.java rename to src/main/java/millions/model/TransactionArchive.java index 0a83e92..20b7e7a 100644 --- a/src/main/java/millions/TransactionArchive.java +++ b/src/main/java/millions/model/TransactionArchive.java @@ -1,9 +1,10 @@ -package millions; +package millions.model; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +/** Stores and queries commited transactions. */ public class TransactionArchive { List transactions; @@ -12,6 +13,11 @@ public TransactionArchive() { this.transactions = new ArrayList<>(); } + /** + * Adds a transaction to the archive + * @param transaction transaction object + * @return Boolean for success + */ public boolean add(Transaction transaction) { if (transactions.contains(transaction)) { return false; @@ -20,21 +26,45 @@ public boolean add(Transaction transaction) { return true; } + /** + * @return Boolean + */ public boolean isEmpty() { return transactions.isEmpty(); } + /** + * @return List of transaction objects + */ + public List getTransactions() { + return new ArrayList<>(transactions); + } + + /** + * returns transaction processed in a given week + * @param week int + * @return List of transaction objects + */ public List getTransactions(int week) { return transactions.stream().filter(x -> x.getWeek() == week).collect(Collectors.toList()); } + /** + * Returns all purchase transactions in a given week + * @param week int + * @return List of transaction objects + */ public List getPurchases(int week) { return transactions.stream() .filter(t -> t.getWeek() == week && t instanceof Purchase) .map(t -> (Purchase) t) .collect(Collectors.toList()); } - + /** + * Returns all sale transactions in a given week + * @param week int + * @return List of transaction objects + */ public List getSales(int week) { return transactions.stream() .filter(t -> t.getWeek() == week && t instanceof Sale) diff --git a/src/main/java/millions/model/calculators/PriceChangeCalculator.java b/src/main/java/millions/model/calculators/PriceChangeCalculator.java new file mode 100644 index 0000000..7ce275a --- /dev/null +++ b/src/main/java/millions/model/calculators/PriceChangeCalculator.java @@ -0,0 +1,64 @@ +package millions.model.calculators; + +import java.math.BigDecimal; +import java.util.List; +import millions.model.Stock; + +public class PriceChangeCalculator { + + public PriceChangeCalculator() {} + + public BigDecimal calculateChange(Stock stock) { + List values = stock.getVolatilityParameters(); + int week = stock.getHistoricalPrices().size(); + BigDecimal change = BigDecimal.ZERO; + change = change.add(drift(values.get(0))); + change = change.add(volatility(values.get(1))); + change = change.add(cycle(values.get(2), values.get(3), week)); + change = change.add(explosion(values.get(4), values.get(5))); + return stock.getSalesPrice().multiply(change); + } + + /* + * Flat slope change, eg upwards/downwards slope + */ + private BigDecimal drift(BigDecimal input) { + if (input.equals(BigDecimal.ZERO)) { + return BigDecimal.ZERO; + } + return input; + } + + /* + * Random noise of size x + */ + private BigDecimal volatility(BigDecimal input) { + if (input.equals(BigDecimal.ZERO)) { + return BigDecimal.ZERO; + } + return input.multiply(BigDecimal.valueOf(Math.random() * 2 - 1)); + } + + /* + * Sinus curve based on week, times size of the curve + */ + private BigDecimal cycle(BigDecimal speed, BigDecimal size, int week) { + if (speed.equals(BigDecimal.ZERO) || size.equals(BigDecimal.ZERO)) { + return BigDecimal.ZERO; + } + return size.multiply(BigDecimal.valueOf(Math.sin(week * speed.doubleValue()))); + } + + /* + * probability% change of an explosion of size% positive or negative + */ + private BigDecimal explosion(BigDecimal probability, BigDecimal size) { + if (probability.equals(BigDecimal.ZERO) || size.equals(BigDecimal.ZERO)) { + return BigDecimal.ZERO; + } + if (Math.random() >= probability.doubleValue()) { + return BigDecimal.ZERO; + } + return Math.random() < 0.5 ? size : size.negate(); + } +} diff --git a/src/main/java/millions/calculators/PurchaseCalculator.java b/src/main/java/millions/model/calculators/PurchaseCalculator.java similarity index 85% rename from src/main/java/millions/calculators/PurchaseCalculator.java rename to src/main/java/millions/model/calculators/PurchaseCalculator.java index e23293c..ab2608d 100644 --- a/src/main/java/millions/calculators/PurchaseCalculator.java +++ b/src/main/java/millions/model/calculators/PurchaseCalculator.java @@ -1,8 +1,9 @@ -package millions.calculators; +package millions.model.calculators; import java.math.BigDecimal; -import millions.Share; +import millions.model.Share; +/** Calculates costs for a purchase transaction. Commission */ public class PurchaseCalculator implements TransactionCalculator { BigDecimal purchasePrice; BigDecimal quantity; diff --git a/src/main/java/millions/calculators/SaleCalculator.java b/src/main/java/millions/model/calculators/SaleCalculator.java similarity index 57% rename from src/main/java/millions/calculators/SaleCalculator.java rename to src/main/java/millions/model/calculators/SaleCalculator.java index 2bddf30..a907b46 100644 --- a/src/main/java/millions/calculators/SaleCalculator.java +++ b/src/main/java/millions/model/calculators/SaleCalculator.java @@ -1,9 +1,10 @@ -package millions.calculators; +package millions.model.calculators; import java.math.BigDecimal; import java.math.RoundingMode; -import millions.Share; +import millions.model.Share; +/** Calculates costs for a sale transaction. Commission and profit tax */ public class SaleCalculator implements TransactionCalculator { BigDecimal purchasePrice; BigDecimal salesPrice; @@ -12,6 +13,8 @@ public class SaleCalculator implements TransactionCalculator { public SaleCalculator(Share share) { super(); this.purchasePrice = share.getPurchasePrice(); + this.salesPrice = share.getStock().getSalesPrice(); + this.quantity = share.getQuantity(); } @Override @@ -21,7 +24,7 @@ public BigDecimal calculateGross() { @Override public BigDecimal calculateCommission() { - return this.calculateGross().divide(new BigDecimal("100"), RoundingMode.HALF_UP); + return this.calculateGross().divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); } @Override @@ -29,13 +32,19 @@ public BigDecimal calculateTax() { BigDecimal purchaseCosts = this.purchasePrice.multiply(this.quantity); BigDecimal earnings = this.calculateGross().subtract(this.calculateCommission()).subtract(purchaseCosts); + if (earnings.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } return earnings .multiply(new BigDecimal("30")) - .divide(new BigDecimal("100"), RoundingMode.HALF_UP); + .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); } @Override public BigDecimal calculateTotal() { - return this.calculateGross().subtract(this.calculateCommission()).subtract(this.calculateTax()); + return this.calculateGross() + .subtract(this.calculateCommission()) + .subtract(this.calculateTax()) + .stripTrailingZeros(); } } diff --git a/src/main/java/millions/calculators/TransactionCalculator.java b/src/main/java/millions/model/calculators/TransactionCalculator.java similarity index 63% rename from src/main/java/millions/calculators/TransactionCalculator.java rename to src/main/java/millions/model/calculators/TransactionCalculator.java index 8b85c6a..9e6ecfb 100644 --- a/src/main/java/millions/calculators/TransactionCalculator.java +++ b/src/main/java/millions/model/calculators/TransactionCalculator.java @@ -1,7 +1,10 @@ -package millions.calculators; +package millions.model.calculators; import java.math.BigDecimal; +/** + * Interface for calculating transaction costs including gross, commission, tax, and total. + */ public interface TransactionCalculator { public BigDecimal calculateGross(); diff --git a/src/main/java/millions/model/factories/PurchaseFactory.java b/src/main/java/millions/model/factories/PurchaseFactory.java new file mode 100644 index 0000000..0af39aa --- /dev/null +++ b/src/main/java/millions/model/factories/PurchaseFactory.java @@ -0,0 +1,13 @@ +package millions.model.factories; + +import millions.model.Purchase; +import millions.model.Share; + +/** + * Factory for creating purchase transactions. + */ +public class PurchaseFactory extends TransactionFactory { + public Purchase createTransaction(Share share, int week) { + return new Purchase(share, week); + } +} diff --git a/src/main/java/millions/model/factories/SaleFactory.java b/src/main/java/millions/model/factories/SaleFactory.java new file mode 100644 index 0000000..6753a5e --- /dev/null +++ b/src/main/java/millions/model/factories/SaleFactory.java @@ -0,0 +1,13 @@ +package millions.model.factories; + +import millions.model.Sale; +import millions.model.Share; + +/** + * Factory for creating sale transactions. + */ +public class SaleFactory extends TransactionFactory { + public Sale createTransaction(Share share, int week) { + return new Sale(share, week); + } +} diff --git a/src/main/java/millions/model/factories/TransactionFactory.java b/src/main/java/millions/model/factories/TransactionFactory.java new file mode 100644 index 0000000..e77d31d --- /dev/null +++ b/src/main/java/millions/model/factories/TransactionFactory.java @@ -0,0 +1,11 @@ +package millions.model.factories; + +import millions.model.Share; +import millions.model.Transaction; + +/** + * Abstract factory for creating transactions. + */ +public abstract class TransactionFactory { + public abstract Transaction createTransaction(Share share, int week); +} diff --git a/src/main/java/millions/view/ExitView.java b/src/main/java/millions/view/ExitView.java new file mode 100644 index 0000000..f90a9e8 --- /dev/null +++ b/src/main/java/millions/view/ExitView.java @@ -0,0 +1,79 @@ +package millions.view; + +import java.math.BigDecimal; +import javafx.application.Platform; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import millions.model.Player; + +public class ExitView extends VBox { + + public ExitView(Player player) { + setSpacing(16); + setAlignment(Pos.CENTER); + setPadding(new Insets(60)); + + Label title = new Label("GAME OVER"); + title.getStyleClass().add("game-over-title"); + HBox titleRow = new HBox(title); + titleRow.setAlignment(Pos.CENTER); + titleRow.setMaxWidth(Double.MAX_VALUE); + + Separator sep = new Separator(); + sep.setMaxWidth(320); + + Label weeks = new Label(String.valueOf(player.getTransactionArchive().countDistinctWeeks())); + Label trades = + new Label(String.valueOf(player.getTransactionArchive().getTransactions().size())); + Label netWorth = new Label(ViewUtils.formatMoney(player.getNetWorth())); + + BigDecimal profit = player.getNetWorth().subtract(player.getStartingMoneh()); + String sign = profit.compareTo(BigDecimal.ZERO) >= 0 ? "+" : ""; + Label netProfit = new Label(sign + ViewUtils.formatMoney(profit)); + netProfit + .getStyleClass() + .add(profit.compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error"); + + GridPane stats = new GridPane(); + stats.setHgap(24); + stats.setVgap(12); + ColumnConstraints labelCol = new ColumnConstraints(); + labelCol.setHalignment(HPos.RIGHT); + labelCol.setHgrow(Priority.NEVER); + ColumnConstraints valueCol = new ColumnConstraints(); + valueCol.setHalignment(HPos.LEFT); + valueCol.setHgrow(Priority.ALWAYS); + stats.getColumnConstraints().addAll(labelCol, valueCol); + + stats.addRow(0, statlabel("Weeks traded"), weeks); + stats.addRow(1, statlabel("Trades made"), trades); + stats.addRow(2, statlabel("Final net worth"), netWorth); + stats.addRow(3, statlabel("Total profit"), netProfit); + + for (Label l : new Label[] {weeks, trades, netWorth, netProfit}) { + l.getStyleClass().add("stat-value"); + } + + Button quitButton = new Button("Quit"); + quitButton.getStyleClass().add("btn-primary"); + quitButton.setPrefWidth(160); + quitButton.setOnAction(event -> Platform.exit()); + + getChildren().addAll(titleRow, sep, stats, quitButton); + } + + private static Label statlabel(String text) { + Label l = new Label(text); + l.getStyleClass().add("section-title"); + return l; + } +} diff --git a/src/main/java/millions/view/GameView.java b/src/main/java/millions/view/GameView.java new file mode 100644 index 0000000..9fefcc3 --- /dev/null +++ b/src/main/java/millions/view/GameView.java @@ -0,0 +1,822 @@ +package millions.view; + +import java.math.BigDecimal; +import java.util.List; +import javafx.beans.property.SimpleStringProperty; // For data binding for table string +import javafx.geometry.Orientation; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.Separator; +import javafx.scene.control.Slider; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import millions.controller.GameController; +import millions.model.Exchange; +import millions.model.ExchangeListener; +import millions.model.Player; +import millions.model.PlayerListener; +import millions.model.Share; +import millions.model.Stock; +import millions.model.Transaction; +import millions.model.calculators.PurchaseCalculator; +import millions.model.calculators.SaleCalculator; + +/** Main game screen with tabs */ +public class GameView extends BorderPane implements PlayerListener, ExchangeListener { + + private final GameController controller; + private final Label playerNameLabel = new Label(); + private final Label weekLabel = new Label(); + private final Label moneyLabel = new Label(); + private final Label netWorthLabel = new Label(); + private final Label statusLabel = new Label(); + + { + for (Label l : + new Label[] {playerNameLabel, weekLabel, moneyLabel, netWorthLabel, statusLabel}) { + l.getStyleClass().add("stat-value"); + } + } + + private final TextField searchField = new TextField(); + private final ListView stocksList = new ListView<>(); + private final TableView portfolioTable = new TableView<>(); + private final TableView transactionsTable = new TableView<>(); + private final Label selectedStockLabel = new Label("Select a stock to see chart"); + private final Label ownedQuantityLabel = new Label("Owned: 0"); + private final Label actionStatusLabel = new Label(); + private final TextField quantityField = new TextField("1"); + private final Slider quantitySlider = new Slider(1, 1, 1); + private final ComboBox ownedSharesBox = new ComboBox<>(); + private final NumberAxis xAxis = new NumberAxis(); + private final NumberAxis yAxis = new NumberAxis(); + private final LineChart stockChart = new LineChart<>(xAxis, yAxis); + private final Button buyButton = new Button("Buy"); + private final Button sellButton = new Button("Sell"); + private final Button advanceButton = new Button("Advance week"); + private final Button sellAllAndQuitButton = new Button("Sell all & quit"); + + private final Label stockHighLabel = new Label(); + private final Label stockLowLabel = new Label(); + private final Label stockChangeLabel = new Label(); + private final Label buyCostPreviewLabel = new Label(); + private final Label sellCostPreviewLabel = new Label(); + private final TableView gainersTable = new TableView<>(); + private final TableView losersTable = new TableView<>(); + private final TextField transactionSearchField = new TextField(); + + { + buyButton.getStyleClass().add("btn-primary"); + advanceButton.getStyleClass().add("btn-primary"); + sellAllAndQuitButton.getStyleClass().add("btn-danger"); + } + + private final Runnable onSellAllAndQuit; + private final TabPane tabPane; + private boolean updatingQuantityControls; + + public GameView(GameController controller, Runnable onSellAllAndQuit) { + this.controller = controller; + this.onSellAllAndQuit = onSellAllAndQuit; + this.tabPane = createTabs(); + setTop(createHeader()); + setCenter(tabPane); + configureStocksList(); + configureButtons(); + configureQuantityControls(); + refreshAll(); + } + + private HBox createHeader() { + HBox title = ViewUtils.buildTitle(); + + javafx.scene.layout.Region spacer = new javafx.scene.layout.Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + HBox header = + new HBox( + 20, + title, + statBox("PLAYER", playerNameLabel), + statBox("WEEK", weekLabel), + statBox("CASH", moneyLabel), + statBox("NET WORTH", netWorthLabel), + statBox("STATUS", statusLabel), + spacer, + sellAllAndQuitButton); + header.getStyleClass().add("header-panel"); + header.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + return header; + } + + private static VBox statBox(String key, Label valueLabel) { + Label keyLabel = new Label(key); + keyLabel.getStyleClass().add("section-title"); + VBox box = new VBox(1, keyLabel, valueLabel); + box.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + return box; + } + + private TabPane createTabs() { + TabPane tabPane = new TabPane(); + tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabPane.getTabs().add(createStocksTab()); + tabPane.getTabs().add(createPortfolioTab()); + tabPane.getTabs().add(createTransactionsTab()); + tabPane.getTabs().add(createMarketTab()); + return tabPane; + } + + private Tab createStocksTab() { + VBox leftPane = new VBox(10, searchField, stocksList); + leftPane.setPrefWidth(500); + stocksList.setPrefWidth(500); + stocksList.setMinWidth(500); + stocksList.setMaxWidth(Double.MAX_VALUE); + + searchField.setPromptText("Search"); + searchField.textProperty().addListener((obs, oldVal, newVal) -> refreshStocks()); + + xAxis.setLabel("Week"); + xAxis.setAutoRanging(false); + xAxis.setLowerBound(1); // Stop week 0 + xAxis.setTickUnit(1); + yAxis.setLabel("Price"); + stockChart.setTitle("Price history"); + stockChart.setLegendVisible(false); + stockChart.setCreateSymbols(true); + stockChart.setAnimated(false); + stockChart.setPrefHeight(500); + stockChart.setMaxWidth(Double.MAX_VALUE); + + selectedStockLabel.getStyleClass().add("selected-stock-label"); + + stockHighLabel.getStyleClass().add("section-title"); + stockLowLabel.getStyleClass().add("section-title"); + stockChangeLabel.getStyleClass().add("section-title"); + HBox stockStats = new HBox(20, stockHighLabel, stockLowLabel, stockChangeLabel); + + VBox rightPane = new VBox(6, selectedStockLabel, stockStats, stockChart); + rightPane.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(rightPane, Priority.ALWAYS); + + quantityField.setPrefWidth(100); + quantityField.setTextFormatter( + new TextFormatter<>( + change -> change.getControlNewText().matches("-?\\d*") ? change : null)); + quantitySlider.setPrefWidth(200); + + ViewUtils.configureOwnedSharesBox(ownedSharesBox); + + Label buyHeader = new Label("BUY"); + buyHeader.getStyleClass().add("section-title"); + HBox buyControls = new HBox(8, quantityField, quantitySlider, buyButton); + buyControls.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + buyCostPreviewLabel.getStyleClass().add("cost-preview"); + VBox buySection = new VBox(4, buyHeader, buyControls, buyCostPreviewLabel); + + javafx.scene.control.Separator sep1 = + new javafx.scene.control.Separator(javafx.geometry.Orientation.VERTICAL); + + Label sellHeader = new Label("SELL"); + sellHeader.getStyleClass().add("section-title"); + HBox sellControls = new HBox(8, ownedQuantityLabel, ownedSharesBox, sellButton); + sellControls.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + sellCostPreviewLabel.getStyleClass().add("cost-preview"); + VBox sellSection = new VBox(4, sellHeader, sellControls, sellCostPreviewLabel); + + javafx.scene.control.Separator sep2 = + new javafx.scene.control.Separator(javafx.geometry.Orientation.VERTICAL); + + Label weekHeader = new Label("WEEK"); + weekHeader.getStyleClass().add("section-title"); + VBox weekSection = new VBox(4, weekHeader, advanceButton); + + HBox actionBar = new HBox(16, buySection, sep1, sellSection, sep2, weekSection); + actionBar.getStyleClass().add("toolbar-panel"); + actionBar.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + + HBox content = new HBox(12, leftPane, rightPane); + HBox.setHgrow(content, Priority.ALWAYS); + VBox outer = new VBox(12, content, actionBar, actionStatusLabel); + return new Tab("Stocks", outer); + } + + private Tab createPortfolioTab() { + portfolioTable.setPlaceholder(new Label("No shares yet")); + + TableColumn symbolColumn = new TableColumn<>("Symbol"); + symbolColumn.setPrefWidth(200); + symbolColumn.setCellValueFactory( + data -> new SimpleStringProperty(data.getValue().getStock().getSymbol())); + + TableColumn quantityColumn = new TableColumn<>("Quantity"); + quantityColumn.setPrefWidth(100); + quantityColumn.setCellValueFactory( + data -> new SimpleStringProperty(data.getValue().getQuantity().toPlainString())); + + TableColumn purchasePriceColumn = new TableColumn<>("Purchase price"); + purchasePriceColumn.setPrefWidth(150); + purchasePriceColumn.setCellValueFactory( + data -> + new SimpleStringProperty(ViewUtils.formatMoney(data.getValue().getPurchasePrice()))); + + TableColumn currentPriceColumn = new TableColumn<>("Current price"); + currentPriceColumn.setPrefWidth(150); + currentPriceColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(data.getValue().getStock().getSalesPrice()))); + + TableColumn profitColumn = new TableColumn<>("Profit"); + profitColumn.setPrefWidth(100); + profitColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(ViewUtils.getShareProfit(data.getValue())))); + profitColumn.setCellFactory( + column -> + new TableCell<>() { + @Override + protected void updateItem(String profit, boolean empty) { + super.updateItem(profit, empty); + getStyleClass().removeAll("status-success", "status-error"); + if (empty) { + setText(null); + return; + } + setText(profit); + getStyleClass().add(profit.startsWith("-") ? "status-error" : "status-success"); + } + }); + TableColumn marketColumn = new TableColumn<>("Market"); + marketColumn.setMinWidth(100); + marketColumn.setStyle("-fx-alignment: CENTER;"); + marketColumn.setCellFactory(column -> new TableCell() { + private final Button button = new Button("Market Page"); + + @Override + protected void updateItem(Share share, boolean empty) { + super.updateItem(share, empty); + + if (empty) { + setGraphic(null); + } else { + button.setOnAction(event -> { + Share s = getTableView().getItems().get(getIndex()); + getTabPane().getSelectionModel().select(0); + stocksList.getSelectionModel().select(s.getStock()); + showStockChart(s.getStock()); + }); + setGraphic(button); + } + } + }); + portfolioTable + .getColumns() + .addAll( + symbolColumn, quantityColumn, purchasePriceColumn, currentPriceColumn, profitColumn, marketColumn); + return new Tab("Portfolio", portfolioTable); + } + + private Tab createTransactionsTab() { + transactionsTable.setPlaceholder(new Label("No transactions yet")); + + TableColumn typeColumn = new TableColumn<>("Type"); + typeColumn.setPrefWidth(100); + typeColumn.setCellValueFactory( + data -> new SimpleStringProperty(data.getValue().getClass().getSimpleName())); + + TableColumn symbolColumn = new TableColumn<>("Symbol"); + symbolColumn.setPrefWidth(100); + symbolColumn.setCellValueFactory( + data -> new SimpleStringProperty(data.getValue().getShare().getStock().getSymbol())); + + TableColumn quantityColumn = new TableColumn<>("Quantity"); + quantityColumn.setPrefWidth(100); + quantityColumn.setCellValueFactory( + data -> new SimpleStringProperty(data.getValue().getShare().getQuantity().toPlainString())); + + TableColumn weekColumn = new TableColumn<>("Week"); + weekColumn.setPrefWidth(100); + weekColumn.setCellValueFactory( + data -> new SimpleStringProperty(String.valueOf(data.getValue().getWeek()))); + + TableColumn grossColumn = new TableColumn<>("Gross"); + grossColumn.setPrefWidth(150); + grossColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(data.getValue().getCalculator().calculateGross()))); + + TableColumn commissionColumn = new TableColumn<>("Commission"); + commissionColumn.setPrefWidth(150); + commissionColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(data.getValue().getCalculator().calculateCommission()))); + + TableColumn taxColumn = new TableColumn<>("Tax"); + taxColumn.setPrefWidth(150); + taxColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(data.getValue().getCalculator().calculateTax()))); + + TableColumn totalColumn = new TableColumn<>("Total"); + totalColumn.setPrefWidth(150); + totalColumn.setCellValueFactory( + data -> + new SimpleStringProperty( + ViewUtils.formatMoney(data.getValue().getCalculator().calculateTotal()))); + + transactionsTable + .getColumns() + .addAll( + typeColumn, + symbolColumn, + quantityColumn, + weekColumn, + grossColumn, + commissionColumn, + taxColumn, + totalColumn); + + transactionSearchField.setPromptText("Search by symbol or type..."); + transactionSearchField + .textProperty() + .addListener((obs, oldVal, newVal) -> refreshTransactions()); + + VBox content = new VBox(8, transactionSearchField, transactionsTable); + VBox.setVgrow(transactionsTable, Priority.ALWAYS); + return new Tab("Transactions", content); + } + + private void configureButtons() { + buyButton.setOnAction(event -> buySelectedStock()); + sellButton.setOnAction(event -> sellSelectedShare()); + advanceButton.setOnAction(event -> advanceWeek()); + sellAllAndQuitButton.setOnAction(event -> sellAllAndQuit()); + ownedSharesBox + .getSelectionModel() + .selectedItemProperty() + .addListener((obs, oldShare, newShare) -> updateSellCostPreview(newShare)); + } + + private void configureQuantityControls() { + quantityField + .textProperty() + .addListener((obs, oldValue, newValue) -> syncQuantityFromField(newValue)); + quantitySlider + .valueProperty() + .addListener((obs, oldValue, newValue) -> syncQuantityFromSlider(newValue.intValue())); + } + + private void configureStocksList() { + stocksList.setCellFactory( + listView -> + new ListCell<>() { + @Override + protected void updateItem(Stock stock, boolean empty) { + super.updateItem(stock, empty); + if (empty || stock == null) { + setText(null); + } else { + setText(ViewUtils.formatStock(stock)); + } + } + }); + + stocksList + .getSelectionModel() + .selectedItemProperty() + .addListener((obs, oldStock, newStock) -> showStockChart(newStock)); + } + + private void refreshAll() { + refreshPlayerInfo(); + refreshStocks(); + refreshPortfolio(); + refreshTransactions(); + refreshMarket(); + } + + private void refreshPlayerInfo() { + Player player = controller.getPlayer(); + Exchange exchange = controller.getExchange(); + + if (player == null || exchange == null) { + return; + } + + playerNameLabel.setText(player.getName()); + weekLabel.setText(String.valueOf(exchange.getWeekNumber())); + moneyLabel.setText(ViewUtils.formatMoney(player.getMoney())); + netWorthLabel.setText(ViewUtils.formatMoney(player.getNetWorth())); + statusLabel.setText(player.getStatus()); + } + + private void refreshStocks() { + Stock selected = stocksList.getSelectionModel().getSelectedItem(); + List items = controller.searchStocks(searchField.getText()); + stocksList.getItems().setAll(items); + + if (selected != null && items.contains(selected)) { + stocksList.getSelectionModel().select(selected); + showStockChart(selected); + } else if (!items.isEmpty()) { + stocksList.getSelectionModel().selectFirst(); + showStockChart(items.getFirst()); + } else { + showStockChart(null); + } + } + + private void showStockChart(Stock stock) { + stockChart.getData().clear(); + + if (stock == null) { + selectedStockLabel.setText("Select a stock to see chart"); + stockHighLabel.setText(""); + stockLowLabel.setText(""); + stockChangeLabel.setText(""); + refreshQuantityControls(null); + return; + } + + selectedStockLabel.setText(stock.getSymbol() + " - " + stock.getCompany()); + + BigDecimal change = stock.getLatestPriceChange(); + String changeSign = change.compareTo(BigDecimal.ZERO) >= 0 ? "+" : ""; + stockHighLabel.setText("High: " + ViewUtils.formatMoney(stock.getHighestPrice())); + stockLowLabel.setText("Low: " + ViewUtils.formatMoney(stock.getLowestPrice())); + stockChangeLabel.setText("Change: " + changeSign + ViewUtils.formatMoney(change)); + stockChangeLabel.getStyleClass().removeAll("status-success", "status-error"); + stockChangeLabel.getStyleClass().add(change.compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error"); + refreshQuantityControls(stock); + + XYChart.Series series = new XYChart.Series<>(); + List prices = stock.getHistoricalPrices(); + xAxis.setUpperBound(Math.max(2, prices.size())); + for (int i = 0; i < prices.size(); i++) { + series.getData().add(new XYChart.Data<>(i + 1, prices.get(i))); + } + stockChart.getData().add(series); + } + + private void refreshQuantityControls(Stock stock) { + if (stock == null) { + ownedQuantityLabel.setText("Owned: 0"); + quantityField.setDisable(true); + quantitySlider.setDisable(true); + buyButton.setDisable(true); + sellButton.setDisable(true); + ownedSharesBox.getItems().clear(); + return; + } + + int maxBuyable = controller.getMaxBuyableQuantity(stock.getSymbol()); + int current = ViewUtils.clampQuantity(getQuantityValue(), Math.max(1, maxBuyable)); + + updatingQuantityControls = true; + quantityField.setText(String.valueOf(current)); + quantitySlider.setMin(1); + quantitySlider.setMax(Math.max(1, maxBuyable)); + quantitySlider.setValue(current); + quantityField.setDisable(false); + quantitySlider.setDisable(false); + buyButton.setDisable(maxBuyable <= 0); + sellButton.setDisable(false); + updatingQuantityControls = false; + + ownedQuantityLabel.setText("Owned: " + controller.getOwnedQuantity(stock.getSymbol())); + ownedSharesBox.getItems().setAll(controller.getOwnedShares(stock.getSymbol())); + if (!ownedSharesBox.getItems().isEmpty()) { + ownedSharesBox.getSelectionModel().selectFirst(); + } + updateBuyCostPreview(stock, current); + updateSellCostPreview(ownedSharesBox.getSelectionModel().getSelectedItem()); + } + + private void refreshPortfolio() { + Player player = controller.getPlayer(); + + portfolioTable.getItems().setAll(player.getPortfolio().getShares()); + } + + private void refreshTransactions() { + Player player = controller.getPlayer(); + if (player == null) return; + + String filter = transactionSearchField.getText().trim().toLowerCase(); + List all = player.getTransactionArchive().getTransactions(); + if (filter.isBlank()) { + transactionsTable.getItems().setAll(all); + } else { + transactionsTable + .getItems() + .setAll( + all.stream() + .filter( + t -> + t.getShare().getStock().getSymbol().toLowerCase().contains(filter) + || t.getClass().getSimpleName().toLowerCase().contains(filter)) + .toList()); + } + } + + private void buySelectedStock() { + Stock selectedStock = stocksList.getSelectionModel().getSelectedItem(); + if (selectedStock == null) { + setActionStatus("Select a stock first.", false); + return; + } + + try { + Transaction transaction = + controller.buyStock(selectedStock.getSymbol(), BigDecimal.valueOf(getQuantityValue())); + var calc = transaction.getCalculator(); + setActionStatus( + "Bought " + + selectedStock.getSymbol() + + " — Gross: " + + ViewUtils.formatMoney(calc.calculateGross()) + + " Commission: " + + ViewUtils.formatMoney(calc.calculateCommission()) + + " Total: " + + ViewUtils.formatMoney(calc.calculateTotal()), + true); + refreshAll(); + showStockChart(selectedStock); + } catch (RuntimeException ex) { + setActionStatus(ex.getMessage(), false); + } + } + + private void sellSelectedShare() { + Stock selectedStock = stocksList.getSelectionModel().getSelectedItem(); + Share selectedShare = ownedSharesBox.getSelectionModel().getSelectedItem(); + if (selectedStock == null) { + setActionStatus("Select a stock first.", false); + return; + } + if (selectedShare == null) { + setActionStatus("Select a share lot.", false); + return; + } + + try { + Transaction transaction = controller.sellShare(selectedShare); + var calc = transaction.getCalculator(); + setActionStatus( + "Sold " + + selectedStock.getSymbol() + + " — Gross: " + + ViewUtils.formatMoney(calc.calculateGross()) + + " Commission: " + + ViewUtils.formatMoney(calc.calculateCommission()) + + " Tax: " + + ViewUtils.formatMoney(calc.calculateTax()) + + " Net: " + + ViewUtils.formatMoney(calc.calculateTotal()), + true); + refreshAll(); + showStockChart(selectedStock); + } catch (RuntimeException ex) { + setActionStatus(ex.getMessage(), false); + } + } + + private void advanceWeek() { + controller.advanceWeek(); + refreshAll(); + showStockChart(stocksList.getSelectionModel().getSelectedItem()); + } + + public void sellAllShares() { + List shares = + new java.util.ArrayList<>(controller.getPlayer().getPortfolio().getShares()); + for (Share share : shares) { + controller.sellShare(share); + } + refreshAll(); + } + + private void sellAllAndQuit() { + sellAllShares(); + onSellAllAndQuit.run(); + } + + private void setActionStatus(String message, boolean success) { + actionStatusLabel.setText(message); + actionStatusLabel.getStyleClass().removeAll("status-success", "status-error"); + actionStatusLabel.getStyleClass().add(success ? "status-success" : "status-error"); + } + + private int getQuantityValue() { + String text = quantityField.getText(); + if (text == null || text.isBlank()) { + return (int) Math.round(quantitySlider.getValue()); + } + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return 1; + } + } + + private void syncQuantityFromField(String newValue) { + if (updatingQuantityControls) { + return; + } + if (newValue == null || newValue.isBlank() || newValue.equals("-")) { + return; + } + + updatingQuantityControls = true; + int clamped = ViewUtils.clampQuantity(parseQuantity(newValue), getCurrentMaxBuyable()); + quantityField.setText(String.valueOf(clamped)); + quantitySlider.setValue(clamped); + updatingQuantityControls = false; + updateBuyCostPreview(stocksList.getSelectionModel().getSelectedItem(), clamped); + } + + private void syncQuantityFromSlider(int newValue) { + if (updatingQuantityControls) { + return; + } + + updatingQuantityControls = true; + int clamped = ViewUtils.clampQuantity(newValue, getCurrentMaxBuyable()); + quantityField.setText(String.valueOf(clamped)); + quantitySlider.setValue(clamped); + updatingQuantityControls = false; + updateBuyCostPreview(stocksList.getSelectionModel().getSelectedItem(), clamped); + } + + private int parseQuantity(String text) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + // Just make anything it can't parse to 1 + return 1; + } + } + + private int getCurrentMaxBuyable() { + Stock selected = stocksList.getSelectionModel().getSelectedItem(); + if (selected == null) { + return 1; + } + return Math.max(1, controller.getMaxBuyableQuantity(selected.getSymbol())); + } + + public TabPane getTabPane() { + return this.tabPane; + } + private String formatStock(Stock stock) { + return ViewUtils.formatStock(stock); + } + + private void updateBuyCostPreview(Stock stock, int quantity) { + if (stock == null || quantity <= 0) { + buyCostPreviewLabel.setText(""); + return; + } + Share tempShare = new Share(stock, BigDecimal.valueOf(quantity), stock.getSalesPrice()); + PurchaseCalculator calc = new PurchaseCalculator(tempShare); + buyCostPreviewLabel.setText( + "Gross: " + + ViewUtils.formatMoney(calc.calculateGross()) + + " Commission: " + + ViewUtils.formatMoney(calc.calculateCommission()) + + " Total: " + + ViewUtils.formatMoney(calc.calculateTotal())); + } + + private void updateSellCostPreview(Share share) { + if (share == null) { + sellCostPreviewLabel.setText(""); + return; + } + SaleCalculator calc = new SaleCalculator(share); + sellCostPreviewLabel.setText( + "Gross: " + + ViewUtils.formatMoney(calc.calculateGross()) + + " Commission: " + + ViewUtils.formatMoney(calc.calculateCommission()) + + " Tax: " + + ViewUtils.formatMoney(calc.calculateTax()) + + " Net: " + + ViewUtils.formatMoney(calc.calculateTotal())); + } + + private Tab createMarketTab() { + gainersTable.setPlaceholder(new Label("Advance a week to see data")); + losersTable.setPlaceholder(new Label("Advance a week to see data")); + + for (TableView table : new TableView[] {gainersTable, losersTable}) { + TableColumn symCol = new TableColumn<>("Symbol"); + symCol.setPrefWidth(100); + symCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().getSymbol())); + + TableColumn nameCol = new TableColumn<>("Company"); + nameCol.setPrefWidth(200); + nameCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().getCompany())); + + TableColumn priceCol = new TableColumn<>("Price"); + priceCol.setPrefWidth(100); + priceCol.setCellValueFactory( + d -> new SimpleStringProperty(ViewUtils.formatMoney(d.getValue().getSalesPrice()))); + + TableColumn changeCol = new TableColumn<>("Change"); + changeCol.setPrefWidth(100); + changeCol.setCellValueFactory( + d -> { + BigDecimal change = d.getValue().getLatestPriceChange(); + String sign = change.compareTo(BigDecimal.ZERO) >= 0 ? "+" : ""; + return new SimpleStringProperty(sign + ViewUtils.formatMoney(change)); + }); + changeCol.setCellFactory( + col -> + new TableCell<>() { + @Override + protected void updateItem(String val, boolean empty) { + super.updateItem(val, empty); + getStyleClass().removeAll("status-success", "status-error"); + if (empty || val == null) { + setText(null); + return; + } + setText(val); + getStyleClass().add(val.startsWith("+") ? "status-success" : "status-error"); + } + }); + table.getColumns().addAll(symCol, nameCol, priceCol, changeCol); + } + + Label gainersTitle = new Label("TOP GAINERS"); + gainersTitle.getStyleClass().add("section-title"); + Label losersTitle = new Label("TOP LOSERS"); + losersTitle.getStyleClass().add("section-title"); + + VBox gainersBox = new VBox(6, gainersTitle, gainersTable); + VBox.setVgrow(gainersTable, Priority.ALWAYS); + VBox losersBox = new VBox(6, losersTitle, losersTable); + VBox.setVgrow(losersTable, Priority.ALWAYS); + + HBox content = new HBox(16, gainersBox, new Separator(Orientation.VERTICAL), losersBox); + HBox.setHgrow(gainersBox, Priority.ALWAYS); + HBox.setHgrow(losersBox, Priority.ALWAYS); + content.setPadding(new javafx.geometry.Insets(12)); + return new Tab("Market", content); + } + + private void refreshMarket() { + Exchange exchange = controller.getExchange(); + if (exchange == null) return; + gainersTable.getItems().setAll(exchange.getGainers(10)); + losersTable.getItems().setAll(exchange.getLosers(10)); + } + + // Listener callbacks update the shared header and the stocks tab. + @Override + public void onMoneyChanged(BigDecimal newBalance) { + refreshAll(); + } + + @Override + public void onPortfolioChanged() { + refreshAll(); + } + + @Override + public void onStatusChanged(String newStatus) { + refreshAll(); + } + + @Override + public void onWeekAdvanced(int newWeek) { + refreshAll(); + } + + @Override + public void onTransactionCompleted(Transaction transaction) { + refreshAll(); + } +} diff --git a/src/main/java/millions/view/StartView.java b/src/main/java/millions/view/StartView.java new file mode 100644 index 0000000..481cff3 --- /dev/null +++ b/src/main/java/millions/view/StartView.java @@ -0,0 +1,175 @@ +package millions.view; + +import java.io.File; +import java.math.BigDecimal; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +/** The initial game setup screen where the player enters their info. */ +public class StartView extends VBox { + private static final Logger logger = Logger.getLogger(StartView.class.getName()); + private TextField nameField; + private TextField startingAmountField; + private TextField preRunWeeksField; + private File selectedFile; + private Button filepickerButton; + private Button startButton; + + public StartView(Stage stage) { + setAlignment(Pos.CENTER); + setSpacing(12); + setPadding(new Insets(40)); + + nameField = new TextField("user"); + nameField.setPromptText("Player name:"); + nameField.setMaxWidth(250); + nameField.textProperty().addListener((obs, oldVal, newVal) -> checkStartButtonValid()); + // Default to 50000 + startingAmountField = new TextField("50000"); + startingAmountField.setPromptText("Starting amount:"); + startingAmountField.setMaxWidth(250); + startingAmountField + .textProperty() + .addListener((obs, oldVal, newVal) -> checkStartButtonValid()); + startingAmountField.setTextFormatter( + new TextFormatter<>( + change -> { + if (change.getControlNewText().matches("([0-9]*)?")) { + return change; + } + return null; + })); + // Pre run weeks to run simulated weeks before the player starts + preRunWeeksField = new TextField("12"); + preRunWeeksField.setPromptText("Pre run weeks:"); + preRunWeeksField.setMaxWidth(250); + preRunWeeksField.textProperty().addListener((obs, oldVal, newVal) -> checkStartButtonValid()); + preRunWeeksField.setTextFormatter( + new TextFormatter<>( + change -> { + if (change.getControlNewText().matches("([0-9]*)?")) { + return change; + } + return null; + })); + + selectedFile = loadDefaultStocksFile(); + + filepickerButton = new Button(); + filepickerButton.setText("Default stocks"); + filepickerButton.setMaxWidth(250); + filepickerButton.setOnAction( + e -> { + FileChooser chooser = new FileChooser(); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV files", "*.csv")); + chooser.setTitle("Select stock CSV file"); + File file = chooser.showOpenDialog(stage); + + if (file != null) { + selectedFile = file; + filepickerButton.setText(file.getName()); + checkStartButtonValid(); + } + }); + + startButton = new Button("Start game"); + startButton.getStyleClass().add("btn-primary"); + startButton.setDisable(true); + + javafx.scene.layout.HBox title = ViewUtils.buildTitle(); + title.getStyleClass().add("title-hero"); + title.setAlignment(javafx.geometry.Pos.CENTER); + + getChildren() + .addAll( + title, + fieldBox("Player name", nameField), + fieldBox("Starting amount ($)", startingAmountField), + fieldBox("Pre-run weeks", preRunWeeksField), + fieldBox("Stock file", filepickerButton), + startButton); + + checkStartButtonValid(); + } + + private File loadDefaultStocksFile() { + try { + URL resource = Objects.requireNonNull(getClass().getResource("/data/default-stocks.csv")); + return Paths.get(resource.toURI()).toFile(); + } catch (URISyntaxException e) { + logger.log(Level.SEVERE, "Error accessing default stocks file", e); + throw new IllegalStateException("Could not load default stocks file", e); + } + } + + /** Enables/Disables start button */ + private void checkStartButtonValid() { + boolean valid = true; + + if (nameField.getText().isBlank()) { + valid = false; + } + + if (selectedFile == null) { + valid = false; + } + + try { + new BigDecimal(startingAmountField.getText()); + } catch (NumberFormatException e) { + valid = false; + } + + try { + if (Integer.parseInt(preRunWeeksField.getText()) < 0) { + valid = false; + } + } catch (NumberFormatException e) { + valid = false; + } + + startButton.setDisable(!valid); + } + + public String getName() { + return nameField.getText(); + } + + public String getStartingAmount() { + return startingAmountField.getText(); + } + + public int getPreRunWeeks() { + return Integer.parseInt(preRunWeeksField.getText()); + } + + public File getSelectedFile() { + return selectedFile; + } + + public Button getStartButton() { + return startButton; + } + + private static javafx.scene.layout.VBox fieldBox(String labelText, javafx.scene.Node field) { + Label label = new Label(labelText); + label.getStyleClass().add("section-title"); + javafx.scene.layout.VBox box = new javafx.scene.layout.VBox(4, label, field); + box.setAlignment(Pos.CENTER_LEFT); + box.setMaxWidth(250); + return box; + } +} diff --git a/src/main/java/millions/view/ViewUtils.java b/src/main/java/millions/view/ViewUtils.java new file mode 100644 index 0000000..a554e0d --- /dev/null +++ b/src/main/java/millions/view/ViewUtils.java @@ -0,0 +1,122 @@ +package millions.view; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import javafx.geometry.Pos; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.layout.HBox; +import millions.model.Share; +import millions.model.Stock; +import millions.model.Transaction; + +/** Small utility helpers for view formatting and simple calculations. */ +public final class ViewUtils { + + private ViewUtils() {} + + public static String formatMoney(BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_UP).toPlainString() + "$"; + } + + public static HBox buildTitle() { + Label million = new Label("MILLION"); + million.getStyleClass().add("title-main"); + Label dollar = new Label("$"); + dollar.getStyleClass().add("title-dollar"); + HBox box = new HBox(0, million, dollar); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + public static void configureOwnedSharesBox(ComboBox comboBox) { + comboBox.setCellFactory( + box -> + new ListCell<>() { + @Override + protected void updateItem(Share share, boolean empty) { + super.updateItem(share, empty); + getStyleClass().removeAll("status-success", "status-error"); + if (empty || share == null) { + setText(null); + } else { + setText(formatOwnedShare(share)); + getStyleClass().add(getProfitClass(share)); + } + } + }); + comboBox.setButtonCell( + new ListCell<>() { + @Override + protected void updateItem(Share share, boolean empty) { + super.updateItem(share, empty); + getStyleClass().removeAll("status-success", "status-error"); + if (empty || share == null) { + setText("Select lot"); + } else { + setText(formatOwnedShare(share)); + getStyleClass().add(getProfitClass(share)); + } + } + }); + } + + public static String formatStock(Stock stock) { + return stock.getSymbol() + + " - " + + stock.getCompany() + + " (" + + formatMoney(stock.getSalesPrice()) + + ")"; + } + + public static String formatOwnedShare(Share share) { + BigDecimal profit = getShareProfit(share); + String sign = profit.compareTo(BigDecimal.ZERO) >= 0 ? "+" : ""; + return share.getQuantity() + + " @ " + + formatMoney(share.getPurchasePrice()) + + " " + + sign + + formatMoney(profit); + } + + public static String getProfitClass(Share share) { + return getShareProfit(share).compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error"; + } + + public static BigDecimal getShareProfit(Share share) { + BigDecimal currentPrice = share.getStock().getSalesPrice(); + return currentPrice.subtract(share.getPurchasePrice()).multiply(share.getQuantity()); + } + + public static int clampQuantity(int quantity, int maxBuyable) { + int upperBound = Math.max(1, maxBuyable); + if (quantity < 1) { + return 1; + } + if (quantity > upperBound) { + return upperBound; + } + return quantity; + } + + public static String formatPortfolioShare(Share share) { + return share.getStock().getSymbol() + + "|" + + share.getQuantity() + + "|" + + share.getPurchasePrice(); + } + + public static String formatTransaction(Transaction transaction) { + return transaction.getClass().getSimpleName() + + "|" + + transaction.getShare().getStock().getSymbol() + + "|" + + transaction.getShare().getQuantity() + + "|" + + transaction.getWeek(); + } +} diff --git a/src/main/resources/data/default-stocks.csv b/src/main/resources/data/default-stocks.csv new file mode 100644 index 0000000..4340bf3 --- /dev/null +++ b/src/main/resources/data/default-stocks.csv @@ -0,0 +1,20 @@ +# Default stock data for Millions +# symbol,name,price,drift;volatility;cycleSpeed;cycleNoise;explosionProbability;explosionSize +AAPL,Pear Inc.,276.43,0.0015;0.022;0.35;0.015;0.015;0.10 +MSFT,MacroHard,404.68,0.0015;0.018;0.30;0.013;0.012;0.08 +GOOGL,Googly Eyes Inc.,187.34,0.002;0.027;0.40;0.018;0.020;0.12 +AMZN,The Great Bazillion,214.10,0.0015;0.024;0.32;0.015;0.015;0.10 +TSLA,Formerly Twitter,342.58,0.002;0.045;0.50;0.027;0.030;0.20 +NVDA,GPUs,191.27,0.002;0.033;0.42;0.021;0.020;0.14 +META,Still Facebook,593.11,0.0015;0.021;0.28;0.014;0.012;0.09 +NFLX,And Chill,982.44,0.002;0.038;0.45;0.024;0.025;0.18 +AMD,Advanced Meme Devices,156.79,0.002;0.036;0.48;0.022;0.022;0.16 +JPM,Just Plain Money Chase,245.33,0.001;0.015;0.20;0.009;0.010;0.06 +WFC,Wealthy Folks Credit,82.15,0.001;0.015;0.18;0.009;0.010;0.05 +BAC,Bank of Awkward Capital,48.92,0.001;0.016;0.18;0.009;0.010;0.05 +DIS,Disknee,112.55,0.0015;0.021;0.30;0.013;0.012;0.08 +KO,Cocaine,67.14,0.001;0.014;0.22;0.008;0.009;0.05 +PEP,Pepe Cola Co.,159.03,0.001;0.015;0.24;0.009;0.010;0.06 +IBM,Incredibly Boring Machines,241.07,0.001;0.014;0.20;0.008;0.009;0.05 +ORCL,Databases?,171.62,0.0015;0.018;0.26;0.011;0.012;0.07 +SAP,Sadly Applying Patches,292.88,0.0015;0.021;0.28;0.013;0.012;0.08 diff --git a/src/main/resources/styles/millions.css b/src/main/resources/styles/millions.css new file mode 100644 index 0000000..8e05692 --- /dev/null +++ b/src/main/resources/styles/millions.css @@ -0,0 +1,190 @@ +.root { + -fx-font-size: 14px; + -fx-background-color: bg; + + bg: #1e1e2e; + bg-elevated: #252535; + bg-hover: #2a2a3e; + bg-component: #2e2e42; + bg-selected: #1a3a2a; + border: #3a3a4a; + text-primary: #e8e8f0; + text-muted: #8888a0; + accent: #22c55e; + danger: #ef4444; +} + +.header-panel { + -fx-background-color: bg; + -fx-padding: 10px 16px; + -fx-border-color: accent border border border; + -fx-border-width: 2px 0 1px 0; + -fx-spacing: 20px; +} + +.toolbar-panel { + -fx-background-color: bg; + -fx-padding: 10px 14px; + -fx-border-color: border; + -fx-border-width: 1px 0 0 0; +} + +.label { -fx-text-fill: text-primary; } +.status-success { -fx-text-fill: accent; } +.status-error { -fx-text-fill: danger; } + +.title { + -fx-font-size: 32px; + -fx-font-weight: bold; + -fx-text-fill: accent; +} + +.title-main, .title-dollar, .game-over-title { + -fx-font-weight: bold; + -fx-font-family: "Georgia"; + -fx-font-style: italic; +} + +.title-main { -fx-font-size: 32px; -fx-text-fill: text-primary; } +.title-dollar { -fx-font-size: 36px; -fx-text-fill: accent; } +.game-over-title { -fx-font-size: 56px; -fx-text-fill: text-primary; } + +.title-hero .title-main { -fx-font-size: 80px; } +.title-hero .title-dollar { -fx-font-size: 88px; } + +.selected-stock-label { + -fx-font-size: 18px; + -fx-font-weight: bold; + -fx-text-fill: accent; +} + +.section-title { + -fx-font-size: 11px; + -fx-font-weight: bold; + -fx-text-fill: text-muted; +} + +.cost-preview { -fx-font-size: 11px; -fx-text-fill: text-muted; -fx-font-family: "monospace"; } +.stat-value { -fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: text-primary; } + +.button { + -fx-background-color: bg-component; + -fx-text-fill: text-primary; + -fx-background-radius: 6px; + -fx-border-color: border; + -fx-border-radius: 6px; + -fx-border-width: 1px; + -fx-padding: 6px 14px; + -fx-cursor: hand; +} + +.button:hover { -fx-background-color: #3a3a52; -fx-border-color: accent; } +.button:pressed { -fx-background-color: #22283a; } +.button:disabled { -fx-opacity: 0.4; } + +.btn-primary { + -fx-background-color: derive(accent, -20%); + -fx-text-fill: white; + -fx-border-color: accent; + -fx-font-weight: bold; +} + +.btn-primary:hover { -fx-background-color: accent; -fx-border-color: derive(accent, 25%); } +.btn-primary:pressed { -fx-background-color: derive(accent, -35%); } + +.btn-danger { + -fx-background-color: derive(danger, -65%); + -fx-text-fill: derive(danger, 40%); + -fx-border-color: danger; +} + +.btn-danger:hover { -fx-background-color: derive(danger, -55%); -fx-border-color: derive(danger, 15%); } +.btn-danger:pressed { -fx-background-color: derive(danger, -75%); } + +.text-field { + -fx-background-color: bg; + -fx-text-fill: text-primary; + -fx-prompt-text-fill: text-muted; + -fx-background-radius: 6px; + -fx-border-color: border; + -fx-border-radius: 6px; + -fx-border-width: 1px; + -fx-padding: 5px 10px; +} + +.text-field:focused, +.combo-box-base:focused { -fx-border-color: accent; } + +.combo-box, .combo-box-base { + -fx-background-color: bg-component; + -fx-background-radius: 6px; + -fx-border-color: border; + -fx-border-radius: 6px; + -fx-border-width: 1px; +} + +.combo-box-base .list-cell { -fx-text-fill: text-primary; } +.combo-box-popup .list-view { -fx-background-color: bg-elevated; -fx-border-color: border; } + +.tab-header-background { -fx-background-color: bg; } + +.tab-pane .tab { + -fx-background-color: bg-elevated; + -fx-background-radius: 6 6 0 0; + -fx-padding: 6px 16px; +} + +.tab-pane .tab:selected { + -fx-background-color: bg; + -fx-border-color: accent transparent transparent transparent; + -fx-border-width: 2px 0 0 0; +} + +.tab-pane .tab-label { -fx-text-fill: text-muted; -fx-font-size: 13px; -fx-font-weight: bold; } +.tab-pane .tab:selected .tab-label { -fx-text-fill: text-primary; } + +.slider .track { -fx-background-color: border; -fx-background-radius: 4px; } +.slider .thumb { -fx-background-color: accent; -fx-background-radius: 50%; -fx-effect: none; } + +.list-view, .table-view { + -fx-background-color: bg; + -fx-control-inner-background: bg; + -fx-border-color: border; + -fx-border-width: 1px; +} + +.list-view { -fx-background-radius: 6px; } +.table-view { -fx-table-cell-border-color: bg-component; -fx-table-header-border-color: border; } + +.table-view .column-header, +.table-view .filler { + -fx-background-color: bg-elevated; + -fx-border-color: border; + -fx-border-width: 0 1px 1px 0; + -fx-padding: 6px 8px; +} + +.table-row-cell { + -fx-background-color: bg; + -fx-text-background-color: text-primary; + -fx-border-color: transparent transparent bg-component transparent; + -fx-border-width: 1px; +} + +/* Every other cell differently collored */ +.table-row-cell:odd, .list-cell:odd { + -fx-background-color: bg-elevated; +} + +.table-row-cell:hover, .list-cell:hover { + -fx-background-color: bg-hover; +} +.table-row-cell:selected { -fx-background-color: bg-selected; } +.table-cell { -fx-text-fill: text-primary; -fx-padding: 6px 8px; } + +.list-cell { -fx-background-color: transparent; -fx-text-fill: text-primary; -fx-padding: 6px 10px; } +.list-cell:selected { -fx-background-color: bg-selected; -fx-text-fill: derive(accent, 25%); } + +.chart, .chart-plot-background, .chart-content { -fx-background-color: bg; } +.chart { -fx-padding: 10px; } +.chart-series-line { -fx-stroke: accent; -fx-stroke-width: 2px; } diff --git a/src/test/java/millions/ExchangeTest.java b/src/test/java/millions/ExchangeTest.java deleted file mode 100644 index a852a0d..0000000 --- a/src/test/java/millions/ExchangeTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class ExchangeTest {} diff --git a/src/test/java/millions/PlayerTest.java b/src/test/java/millions/PlayerTest.java deleted file mode 100644 index 62a3263..0000000 --- a/src/test/java/millions/PlayerTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class PlayerTest {} diff --git a/src/test/java/millions/PortfolioTest.java b/src/test/java/millions/PortfolioTest.java deleted file mode 100644 index 3fb1019..0000000 --- a/src/test/java/millions/PortfolioTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class PortfolioTest {} diff --git a/src/test/java/millions/PurchaseTest.java b/src/test/java/millions/PurchaseTest.java deleted file mode 100644 index a67c37a..0000000 --- a/src/test/java/millions/PurchaseTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class PurchaseTest {} diff --git a/src/test/java/millions/SaleTest.java b/src/test/java/millions/SaleTest.java deleted file mode 100644 index b808f7a..0000000 --- a/src/test/java/millions/SaleTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class SaleTest {} diff --git a/src/test/java/millions/ShareTest.java b/src/test/java/millions/ShareTest.java deleted file mode 100644 index cf3d676..0000000 --- a/src/test/java/millions/ShareTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class ShareTest {} diff --git a/src/test/java/millions/StockTest.java b/src/test/java/millions/StockTest.java deleted file mode 100644 index eda8962..0000000 --- a/src/test/java/millions/StockTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class StockTest {} diff --git a/src/test/java/millions/TransactionArchiveTest.java b/src/test/java/millions/TransactionArchiveTest.java deleted file mode 100644 index bdc9ca8..0000000 --- a/src/test/java/millions/TransactionArchiveTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class TransactionArchiveTest {} diff --git a/src/test/java/millions/TransactionTest.java b/src/test/java/millions/TransactionTest.java deleted file mode 100644 index 9074225..0000000 --- a/src/test/java/millions/TransactionTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package millions; - -import static org.junit.jupiter.api.Assertions.*; - -class TransactionTest {} diff --git a/src/test/java/millions/controller/GameControllerTest.java b/src/test/java/millions/controller/GameControllerTest.java new file mode 100644 index 0000000..fd63cc6 --- /dev/null +++ b/src/test/java/millions/controller/GameControllerTest.java @@ -0,0 +1,27 @@ +package millions.controller; + +import millions.controller.fileIO.UncheckedFileNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class GameControllerTest { + GameController gameController; + @BeforeEach + public void setUpController() { + this.gameController = new GameController(); + } + + @Test + public void startGameFileNotFoundTest() { + String name = "name"; + BigDecimal startingMoney = new BigDecimal("100"); + Path stockFilePath = Path.of("nonexistantfile"); + int prerunWeeks = 1; + assertThrows(UncheckedFileNotFoundException.class, () -> {gameController.startGame(name,startingMoney,stockFilePath,prerunWeeks);}); + } +} diff --git a/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java b/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java new file mode 100644 index 0000000..efefb7d --- /dev/null +++ b/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java @@ -0,0 +1,74 @@ +package millions.controller.fileIO.CSV; + +import millions.controller.fileIO.InvalidFormatException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + + +import java.util.List; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CSVStockFileParserTest { + + static String exampleString; + final CSVStockFileParser parser = new CSVStockFileParser(); + + @BeforeEach + public void setUpTestString() { + exampleString = "# Top 500 US Stocks by Market Cap\n"; + exampleString += "# Ticker,Name,Price\n"; + exampleString += "\n"; + exampleString += "NVDA,Nvidia,191.27\n"; + exampleString += "AAPL,Apple Inc.,276.43\n"; + exampleString += "MSFT,Microsoft,404.68\n"; + + } + + @Test + public void parseStockFileTest(){ + List testList = List.of(exampleString.split("\n")); + assertEquals(3, parser.parse(testList).size()); + } + + @Test + public void InvalidFormatExceptionTest() { + exampleString += "Line with incorrect amount of data"; + List testList = List.of(exampleString.split("\n")); + Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);}); + String expectedMessage = "Incorrect format for CSV File: incorrect amount of data fields detected on one or more lines"; + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void NumberConversionExceptionTest() { + exampleString += "Company, Company Inc., NotANumber"; + List testList = List.of(exampleString.split("\n")); + Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);}); + String expectedMessage = "Error with number conversion on line: Company, Company Inc., NotANumber\n" + + "Last field must be a number"; + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void EmptySymbolFieldExceptionTest() { + exampleString += ",test,1"; + List testList = List.of(exampleString.split("\n")); + Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);}); + String expectedMessage = "Illegal argument on line: ,test,1\n" + + "Symbol cannot be null or blank"; + assertEquals(expectedMessage, e.getMessage()); + } + @Test + public void EmptyCompanyNameFieldExceptionTest() { + exampleString += "test,,1"; + List testList = List.of(exampleString.split("\n")); + Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);}); + String expectedMessage = "Illegal argument on line: test,,1\n" + + "Company cannot be null or blank"; + assertEquals(expectedMessage, e.getMessage()); + } +} diff --git a/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java b/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java new file mode 100644 index 0000000..159c948 --- /dev/null +++ b/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java @@ -0,0 +1,35 @@ +package millions.controller.fileIO.CSV; + +import millions.controller.fileIO.StockFileReader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class StockFileReaderTest { + @TempDir + static Path tempDir; + + static Path sharedFile; + @BeforeAll + public static void setUpTestFile() throws Exception { + sharedFile = Files.createFile(tempDir.resolve("file.csv")); + String string = "# Top 500 US Stocks by Market Cap\n"; + string += "# Ticker,Name,Price\n"; + string += "\n"; + string += "NVDA,Nvidia,191.27\n"; + string += "AAPL,Apple Inc.,276.43\n"; + string += "MSFT,Microsoft,404.68\n"; + Files.writeString(sharedFile, string); + } + + @Test + public void testReadStockFile() { + StockFileReader stockFileReader = new StockFileReader(); + assertEquals(6, stockFileReader.readFile(sharedFile).size()); + } +} diff --git a/src/test/java/millions/model/ExchangeListenerTest.java b/src/test/java/millions/model/ExchangeListenerTest.java new file mode 100644 index 0000000..935ab43 --- /dev/null +++ b/src/test/java/millions/model/ExchangeListenerTest.java @@ -0,0 +1,91 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/*** + * Small test class that implements the ExchangeListener. Adds to a list, so that we can easily check lengths and values + */ +class TestExchangeListener implements ExchangeListener { + List weekEvents = new ArrayList<>(); + List transactionEvents = new ArrayList<>(); + + @Override + public void onWeekAdvanced(int newWeek) { + weekEvents.add(newWeek); + } + + @Override + public void onTransactionCompleted(Transaction transaction) { + transactionEvents.add(transaction); + } +} + +class ExchangeListenerTest { + + private Exchange exchange; + private Player player; + private TestExchangeListener listener; + + @BeforeEach + void setUp() { + Stock s1 = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100)); + Stock s2 = new Stock("GOOG", "Alphabet Inc.", BigDecimal.valueOf(200)); + Stock s3 = new Stock("NVDA", "NVidia Inc.", BigDecimal.valueOf(200)); + exchange = new Exchange("NASDAQ", List.of(s1, s2, s3)); + player = new Player("TestPlayer", BigDecimal.valueOf(10000)); + + listener = new TestExchangeListener(); + exchange.addListener(listener); + } + + @Test + void advanceNotifiesListener() { + exchange.advance(); + assertEquals(1, listener.weekEvents.size()); + assertEquals(2, listener.weekEvents.getFirst()); + + exchange.advance(); + assertEquals(2, listener.weekEvents.size()); + assertEquals(3, listener.weekEvents.get(1)); + } + + @Test + void buyNotifiesListener() { + exchange.buy("AAPL", player, 1); + assertEquals(1, listener.transactionEvents.size()); + assertTrue(listener.transactionEvents.getFirst() instanceof Purchase); + exchange.buy("NVDA", player, 1); + assertTrue( + listener.transactionEvents.getLast().getShare().getStock().getSymbol().equals("NVDA")); + } + + @Test + void sellNotifiesListener() { + exchange.buy("AAPL", player, 1); + listener.transactionEvents.clear(); + + Share share = player.getPortfolio().getShares().getFirst(); + exchange.sell(share, player); + + assertEquals(1, listener.transactionEvents.size()); + assertTrue(listener.transactionEvents.getFirst() instanceof Sale); + } + + @Test + void removeListenerStopsNotifications() { + exchange.advance(); + assertEquals(1, listener.weekEvents.size()); + + exchange.removeListener(listener); + exchange.advance(); + assertEquals(1, listener.weekEvents.size()); + } +} diff --git a/src/test/java/millions/model/ExchangeTest.java b/src/test/java/millions/model/ExchangeTest.java new file mode 100644 index 0000000..2e689a6 --- /dev/null +++ b/src/test/java/millions/model/ExchangeTest.java @@ -0,0 +1,135 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; + +class ExchangeTest { + @Test + public void testGetters() { + Stock s1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Stock s2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00)); + Stock s3 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02)); + + Exchange exchange = new Exchange("exchange", List.of(s1, s2)); + + assertFalse(exchange.hasStock("MSFT")); + assertTrue(exchange.getStock("DOGL").equals(s2)); + assertNull(exchange.getStock("XYZ")); + assertTrue(exchange.findStocks("Amozon").isEmpty()); + + assertTrue(exchange.hasStock("DOGL")); + assertFalse(exchange.findStocks("Pear").isEmpty()); + assertFalse(exchange.findStocks("PE").isEmpty()); + + assertTrue(exchange.findStocks("Inc").size() == 2); + } + + @Test + public void happyPath() { + Stock s1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Stock s2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00)); + Stock s3 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02)); + + Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3)); + Player player = new Player("name", BigDecimal.valueOf(1000)); + + BigDecimal previousMoney = player.getMoney(); + exchange.buy("PEAR", player, 2); + assertTrue(previousMoney.compareTo(player.getMoney()) > 0); + + BigDecimal previousPrice = s1.getSalesPrice(); + exchange.advance(); + assertFalse(previousPrice.equals(s1.getSalesPrice())); + + Share share = player.getPortfolio().getShares().getFirst(); + + previousMoney = player.getMoney(); + exchange.sell(share, player); + assertTrue(previousMoney.compareTo(player.getMoney()) < 0); + assertTrue(player.getPortfolio().getShares().isEmpty()); + } + + @Test + public void testNullsAndInvalid() { + Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02)); + + Exchange exchange = new Exchange("exchange", List.of(s1)); + Player player = new Player("name", BigDecimal.valueOf(1000)); + + Share unownedShare = new Share(s1, BigDecimal.valueOf(1), s1.getSalesPrice()); + assertThrows(IllegalStateException.class, () -> exchange.sell(unownedShare, player)); + + Player noMoney = new Player("nomoney", BigDecimal.valueOf(0)); + assertThrows(IllegalStateException.class, () -> exchange.buy("MSFT", noMoney, 1)); + + assertThrows( + IllegalArgumentException.class, + () -> new Exchange("", List.of(new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300))))); + + assertThrows( + IllegalArgumentException.class, + () -> new Exchange(null, List.of(new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300))))); + + assertThrows(IllegalArgumentException.class, () -> exchange.buy("DOGL", player, 2)); + + assertThrows(IllegalArgumentException.class, () -> exchange.buy("DOGL", player, -2)); + + assertThrows( + IllegalArgumentException.class, () -> exchange.buy("DOGL", player, BigDecimal.valueOf(-2))); + } + + @Test + public void testGetGainers() { + Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02)); + Stock s2 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Stock s3 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00)); + + Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3)); + exchange.advance(); + List gainers = exchange.getGainers(3); + + boolean isSorted = + IntStream.range(0, gainers.size() - 1) + .allMatch( + i -> + gainers + .get(i) + .getLatestPriceChange() + .compareTo(gainers.get(i + 1).getLatestPriceChange()) + >= 0); + + assertTrue(isSorted); + assertEquals(3, gainers.size()); + } + + @Test + public void testGetLosers() { + Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02)); + Stock s2 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Stock s3 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00)); + + Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3)); + + List losers = exchange.getLosers(3); + for (Stock s : losers) { + System.out.println(s.getLatestPriceChange()); + } + boolean isSorted = + IntStream.range(0, losers.size() - 1) + .allMatch( + i -> + losers + .get(i) + .getLatestPriceChange() + .compareTo(losers.get(i + 1).getLatestPriceChange()) + <= 0); + + assertTrue(isSorted); + assertEquals(3, losers.size()); + } +} diff --git a/src/test/java/millions/model/PlayerListenerTest.java b/src/test/java/millions/model/PlayerListenerTest.java new file mode 100644 index 0000000..71c94d6 --- /dev/null +++ b/src/test/java/millions/model/PlayerListenerTest.java @@ -0,0 +1,96 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/*** + * Small test class that implements the PlayerListener. Adds to a list, so that we can easily check lengths and values + */ + +class TestPlayerListener implements PlayerListener { + List moneyEvents = new ArrayList<>(); + int portfolioChangedCount = 0; + List statusEvents = new ArrayList<>(); + + @Override + public void onMoneyChanged(BigDecimal newBalance) { + moneyEvents.add(newBalance); + } + + @Override + public void onPortfolioChanged() { + portfolioChangedCount++; + } + + @Override + public void onStatusChanged(String newStatus) { + statusEvents.add(newStatus); + } +} + +class PlayerListenerTest { + + private Player player; + private TestPlayerListener listener; + + @BeforeEach + void setUp() { + player = new Player("TestPlayer", BigDecimal.valueOf(10000)); + listener = new TestPlayerListener(); + player.addListener(listener); + } + + @Test + void addMoneyNotifiesListener() { + player.addMoney(BigDecimal.valueOf(500)); + assertEquals(1, listener.moneyEvents.size()); + assertEquals(BigDecimal.valueOf(10500), listener.moneyEvents.getFirst()); + } + + @Test + void withdrawMoneyNotifiesListener() { + player.withdrawMoney(BigDecimal.valueOf(300)); + assertEquals(1, listener.moneyEvents.size()); + assertEquals(BigDecimal.valueOf(9700), listener.moneyEvents.getFirst()); + } + + @Test + void addShareNotifiesPortfolioAndStatus() { + Stock stock = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100)); + Share share = new Share(stock, BigDecimal.valueOf(1), BigDecimal.valueOf(100)); + + player.addShareToPortfolio(share); + assertEquals(1, listener.portfolioChangedCount); + assertEquals(1, listener.statusEvents.size()); + } + + @Test + void removeShareNotifiesPortfolioAndStatus() { + Stock stock = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100)); + Share share = new Share(stock, BigDecimal.valueOf(1), BigDecimal.valueOf(100)); + + player.addShareToPortfolio(share); + listener.portfolioChangedCount = 0; + listener.statusEvents.clear(); + + player.removeShareFromPortfolio(share); + assertEquals(1, listener.portfolioChangedCount); + assertEquals(1, listener.statusEvents.size()); + } + + @Test + void removeListenerStopsNotifications() { + player.addMoney(BigDecimal.valueOf(100)); + assertEquals(1, listener.moneyEvents.size()); + + player.removeListener(listener); + player.addMoney(BigDecimal.valueOf(100)); + assertEquals(1, listener.moneyEvents.size()); + } +} diff --git a/src/test/java/millions/model/PlayerTest.java b/src/test/java/millions/model/PlayerTest.java new file mode 100644 index 0000000..2cefd2d --- /dev/null +++ b/src/test/java/millions/model/PlayerTest.java @@ -0,0 +1,57 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +class PlayerTest { + + @Test + public void happyPath() { + Player player = new Player("name", BigDecimal.valueOf(1000)); + assertEquals(BigDecimal.valueOf(1000), player.getMoney()); + + player.addMoney(BigDecimal.valueOf(500)); + assertEquals(BigDecimal.valueOf(1500), player.getMoney()); + + player.withdrawMoney(BigDecimal.valueOf(200)); + assertEquals(BigDecimal.valueOf(1300), player.getMoney()); + } + + @Test + public void testGetters() { + Player player = new Player("name", BigDecimal.valueOf(1000)); + assertEquals("name", player.getName()); + assertEquals(BigDecimal.valueOf(1000), player.getMoney()); + assertTrue(player.getPortfolio().getShares().isEmpty()); + assertTrue(player.getTransactionArchive().isEmpty()); + } + + @Test + public void testNullsAndInvalid() { + assertThrows(IllegalArgumentException.class, () -> new Player(null, BigDecimal.valueOf(1000))); + assertThrows(IllegalArgumentException.class, () -> new Player("", BigDecimal.valueOf(1000))); + assertThrows(IllegalArgumentException.class, () -> new Player("name", null)); + assertThrows(IllegalArgumentException.class, () -> new Player("name", BigDecimal.valueOf(-1))); + } + + // @Test + // public void testStatus() { + // Player player = new Player("name", BigDecimal.valueOf(1000)); + // assertEquals("Novice", player.getStatus()); + // + // player.addMoney(BigDecimal.valueOf(200)); + // assertEquals("Novice", player.getStatus()); + // + // player.weeksTraded = 10; + // assertEquals("Investor", player.getStatus()); + // + // player.addMoney(BigDecimal.valueOf(200)); + // assertEquals("Investor", player.getStatus()); + // + // player.weeksTraded = 20; + // assertEquals("Speculator", player.getStatus()); + // } +} diff --git a/src/test/java/millions/model/PortfolioTest.java b/src/test/java/millions/model/PortfolioTest.java new file mode 100644 index 0000000..a208066 --- /dev/null +++ b/src/test/java/millions/model/PortfolioTest.java @@ -0,0 +1,76 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +class PortfolioTest { + + @Test + public void happyPath() { + Portfolio portfolio = new Portfolio(); + Stock stock = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Share share = new Share(stock, 10, BigDecimal.valueOf(300)); + + assertTrue(portfolio.addShare(share)); + assertTrue(portfolio.contains(share)); + assertEquals(1, portfolio.getShares().size()); + assertEquals(share, portfolio.getShares().get(0)); + + assertTrue(portfolio.removeShare(share)); + assertFalse(portfolio.contains(share)); + assertTrue(portfolio.getShares().isEmpty()); + } + + @Test + public void testGettersAndSetters() { + Portfolio portfolio = new Portfolio(); + Stock stock1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Stock stock2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00)); + Share share1 = new Share(stock1, 10, BigDecimal.valueOf(300)); + Share share2 = new Share(stock2, 5, BigDecimal.valueOf(200)); + + portfolio.addShare(share1); + portfolio.addShare(share2); + + assertEquals(2, portfolio.getShares().size()); + assertTrue(portfolio.getShares().contains(share1)); + assertTrue(portfolio.getShares().contains(share2)); + + assertEquals(1, portfolio.getShares("PEAR").size()); + assertTrue(portfolio.getShares("PEAR").contains(share1)); + assertFalse(portfolio.getShares("PEAR").contains(share2)); + + assertEquals(1, portfolio.getShares("DOGL").size()); + assertFalse(portfolio.getShares("DOGL").contains(share1)); + assertTrue(portfolio.getShares("DOGL").contains(share2)); + + assertTrue(portfolio.getShares("XYZ").isEmpty()); + + portfolio.removeShare(share1); + assertEquals(1, portfolio.getShares().size()); + } + + @Test + public void testGetNetWorth() { + Portfolio portfolio = new Portfolio(); + Stock stock = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(100)); + Share share = new Share(stock, 1, BigDecimal.valueOf(50)); + + portfolio.addShare(share); + + assertEquals(new BigDecimal("84.3"), portfolio.getNetWorth()); + } + + @Test + public void testNullsAndInvalid() { + Stock stock1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)); + Share share1 = new Share(stock1, 10, BigDecimal.valueOf(300)); + Portfolio portfolio = new Portfolio(); + assertFalse(portfolio.removeShare(null)); + assertFalse(portfolio.contains(null)); + assertFalse(portfolio.removeShare(share1)); + } +} diff --git a/src/test/java/millions/model/PurchaseTest.java b/src/test/java/millions/model/PurchaseTest.java new file mode 100644 index 0000000..3c76e85 --- /dev/null +++ b/src/test/java/millions/model/PurchaseTest.java @@ -0,0 +1,41 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +class PurchaseTest { + @Test + public void testHappyPath() { + Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10)); + Share share = new Share(stock, 2, BigDecimal.valueOf(10)); + Player player = new Player("TestPlayer", BigDecimal.valueOf(100)); + Purchase purchase = new Purchase(share, 1); + purchase.commit(player); + assertTrue(purchase.isCommitted()); + + // 1 less because of tax + assertEquals(79, player.getMoney().intValue()); + assertTrue(player.getPortfolio().getShares().contains(share)); + } + + @Test + public void testNullsAndInvalid() { + + Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10)); + Share share = new Share(stock, 2, BigDecimal.valueOf(10)); + Player player = new Player("TestPlayer", BigDecimal.valueOf(100)); + Purchase purchase = new Purchase(share, 1); + + // Double commit + purchase.commit(player); + assertThrows(IllegalStateException.class, () -> purchase.commit(player)); + + // Insufficient funds + Player poorPlayer = new Player("PoorPlayer", BigDecimal.valueOf(10)); + Purchase expensivePurchase = new Purchase(new Share(stock, 20, BigDecimal.valueOf(10)), 1); + assertThrows(IllegalStateException.class, () -> expensivePurchase.commit(poorPlayer)); + } +} diff --git a/src/test/java/millions/model/SaleTest.java b/src/test/java/millions/model/SaleTest.java new file mode 100644 index 0000000..4a8a4d1 --- /dev/null +++ b/src/test/java/millions/model/SaleTest.java @@ -0,0 +1,33 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +class SaleTest { + + @Test + public void testHappyPath() { + Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10)); + Share share = new Share(stock, 2, BigDecimal.valueOf(10)); + Player player = new Player("TestPlayer", BigDecimal.valueOf(100)); + player.getPortfolio().addShare(share); + Sale sale = new Sale(share, 1); + sale.commit(player); + assertTrue(sale.isCommitted()); + + assertEquals(119, player.getMoney().intValue()); + assertFalse(player.getPortfolio().getShares().contains(share)); + } + + @Test + public void testNullsAndInvalid() { + Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10)); + Share share = new Share(stock, 2, BigDecimal.valueOf(10)); + Player player = new Player("TestPlayer", BigDecimal.valueOf(100)); + Sale sale = new Sale(share, 1); + assertThrows(IllegalStateException.class, () -> sale.commit(player)); + } +} diff --git a/src/test/java/millions/model/ShareTest.java b/src/test/java/millions/model/ShareTest.java new file mode 100644 index 0000000..3f82181 --- /dev/null +++ b/src/test/java/millions/model/ShareTest.java @@ -0,0 +1,35 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +class ShareTest { + + @Test + public void testHappyPath() { + Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10)); + Share share = new Share(stock, 5, BigDecimal.valueOf(10)); + assertEquals(BigDecimal.valueOf(5), share.getQuantity()); + } + + @Test + public void testGetters() { + Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10)); + Share share = new Share(stock, 5, BigDecimal.valueOf(10)); + assertEquals(BigDecimal.valueOf(5), share.getQuantity()); + assertEquals(stock, share.getStock()); + assertEquals(BigDecimal.valueOf(10), share.getPurchasePrice()); + } + + @Test + public void testNullsAndInvalid() { + Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10)); + assertThrows(IllegalArgumentException.class, () -> new Share(null, 2, BigDecimal.valueOf(2))); + assertThrows(IllegalArgumentException.class, () -> new Share(stock, 2, null)); + assertThrows( + IllegalArgumentException.class, () -> new Share(stock, null, BigDecimal.valueOf(2))); + } +} diff --git a/src/test/java/millions/model/StockTest.java b/src/test/java/millions/model/StockTest.java new file mode 100644 index 0000000..c28a6bd --- /dev/null +++ b/src/test/java/millions/model/StockTest.java @@ -0,0 +1,51 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class StockTest { + @Test + public void testHapyPath() { + Stock stock = new Stock("NVDA", "Nvadia", BigDecimal.valueOf(20)); + Stock stock2 = + new Stock( + "NVDA", "Nvadia", new ArrayList(Arrays.asList(BigDecimal.valueOf(20)))); + stock2.addNewSalesPrice(BigDecimal.valueOf(30)); + assertEquals(BigDecimal.valueOf(30), stock2.getSalesPrice()); + } + + @Test + public void settersAndGetters() { + Stock stock = new Stock("NVDA", "Nvadia", BigDecimal.valueOf(20)); + assertEquals("NVDA", stock.getSymbol()); + assertEquals("Nvadia", stock.getCompany()); + } + + @Test + public void testGetPriceChange() { + ArrayList prices = new ArrayList<>(List.of(BigDecimal.valueOf(100), BigDecimal.valueOf(125))); + Stock stock = new Stock("AAPL", "Apple", prices); + assertEquals(BigDecimal.valueOf(25), stock.getLatestPriceChange()); + stock.addNewSalesPrice(BigDecimal.valueOf(155)); + assertEquals(BigDecimal.valueOf(30), stock.getLatestPriceChange()); + } + + @Test + public void testNullsAndInvalid() { + + assertThrows( + IllegalArgumentException.class, () -> new Stock(null, "Nvadia", BigDecimal.valueOf(20))); + assertThrows( + IllegalArgumentException.class, () -> new Stock("NVDA", null, BigDecimal.valueOf(20))); + assertThrows( + IllegalArgumentException.class, () -> new Stock("NVDA", "", BigDecimal.valueOf(20))); + assertThrows( + IllegalArgumentException.class, () -> new Stock("", "Nvadia", BigDecimal.valueOf(20))); + } +} diff --git a/src/test/java/millions/model/TransactionArchiveTest.java b/src/test/java/millions/model/TransactionArchiveTest.java new file mode 100644 index 0000000..426ed5b --- /dev/null +++ b/src/test/java/millions/model/TransactionArchiveTest.java @@ -0,0 +1,47 @@ +package millions.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TransactionArchiveTest { + + TransactionArchive archive; + Purchase purchase; + Sale sale; + + @BeforeEach + public void setUp() { + archive = new TransactionArchive(); + Stock stock = new Stock("SUS", "Samsung", BigDecimal.valueOf(10)); + Share share = new Share(stock, 2, BigDecimal.valueOf(10)); + purchase = new Purchase(share, 1); + sale = new Sale(share, 2); + } + + @Test + public void testHappyPath() { + assertTrue(archive.isEmpty()); + archive.add(purchase); + assertFalse(archive.isEmpty()); + } + + @Test + public void testNullsAndInvalid() { + archive.add(purchase); + assertFalse(archive.add(purchase)); + } + + @Test + public void testGetters() { + archive.add(purchase); + archive.add(sale); + assertEquals(List.of(purchase), archive.getPurchases(1)); + assertEquals(List.of(sale), archive.getSales(2)); + assertEquals(2, archive.countDistinctWeeks()); + } +} diff --git a/src/test/java/millions/calculators/PurchaseCalculatorTest.java b/src/test/java/millions/model/calculators/PurchaseCalculatorTest.java similarity index 70% rename from src/test/java/millions/calculators/PurchaseCalculatorTest.java rename to src/test/java/millions/model/calculators/PurchaseCalculatorTest.java index 2915b5e..37d8cca 100644 --- a/src/test/java/millions/calculators/PurchaseCalculatorTest.java +++ b/src/test/java/millions/model/calculators/PurchaseCalculatorTest.java @@ -1,4 +1,4 @@ -package millions.calculators; +package millions.model.calculators; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/millions/calculators/SaleCalculatorTest.java b/src/test/java/millions/model/calculators/SaleCalculatorTest.java similarity index 68% rename from src/test/java/millions/calculators/SaleCalculatorTest.java rename to src/test/java/millions/model/calculators/SaleCalculatorTest.java index 3d7cebb..ea19922 100644 --- a/src/test/java/millions/calculators/SaleCalculatorTest.java +++ b/src/test/java/millions/model/calculators/SaleCalculatorTest.java @@ -1,4 +1,4 @@ -package millions.calculators; +package millions.model.calculators; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/millions/calculators/TransactionCalculatorTest.java b/src/test/java/millions/model/calculators/TransactionCalculatorTest.java similarity index 70% rename from src/test/java/millions/calculators/TransactionCalculatorTest.java rename to src/test/java/millions/model/calculators/TransactionCalculatorTest.java index ad398f8..b65dc0e 100644 --- a/src/test/java/millions/calculators/TransactionCalculatorTest.java +++ b/src/test/java/millions/model/calculators/TransactionCalculatorTest.java @@ -1,4 +1,4 @@ -package millions.calculators; +package millions.model.calculators; import static org.junit.jupiter.api.Assertions.*;