diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/Main.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/Main.java index 3caea4b..cec044e 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/Main.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/Main.java @@ -2,9 +2,11 @@ import edu.ntnu.idi.idatt2003.g40.mappe.engine.Exchange; import edu.ntnu.idi.idatt2003.g40.mappe.model.Player; +import edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame; import edu.ntnu.idi.idatt2003.g40.mappe.model.Stock; import edu.ntnu.idi.idatt2003.g40.mappe.service.FileConverter; import edu.ntnu.idi.idatt2003.g40.mappe.service.FileParser; +import edu.ntnu.idi.idatt2003.g40.mappe.service.GameStateLoader; import edu.ntnu.idi.idatt2003.g40.mappe.service.SaveGameService; import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventManager; import edu.ntnu.idi.idatt2003.g40.mappe.utils.ConfigValues; @@ -94,6 +96,11 @@ public void start(final Stage stage) throws Exception { Exchange exchange = new Exchange("Exchange", stocksInFile); Player player = new Player("Player 1", new BigDecimal("10000")); + // Bridges the save list to the live player + exchange. Subscribes + // to LOAD_SAVE; mutates state; publishes STATE_RESET for widgets. + GameStateLoader gameStateLoader = new GameStateLoader( + player, exchange, stocksInFile, eventManager); + // Main menu MainMenuView mainMenuView = new MainMenuView(); new MainMenuController(mainMenuView, eventManager); @@ -210,6 +217,30 @@ public void start(final Stage stage) throws Exception { new InGameSettingsController(inGameSettingsView, eventManager, inGameView); topBarController.setSettingsAction(inGameSettingsController::show); + // Auto-save the currently active save when the player quits back + // to the main menu. Silent if no save is active (i.e. the user + // got into the in-game scene without going through the save list). + topBarController.setOnQuitToMainMenu(() -> { + System.out.println("[auto-save] Quit triggered, attempting snapshot..."); + SaveGame snapshot = gameStateLoader.snapshotActiveSave(); + if (snapshot == null) { + System.out.println("[auto-save] No active save - nothing to write."); + return; + } + System.out.println("[auto-save] Snapshot built for '" + snapshot.getName() + + "', balance=" + snapshot.getBalance() + + ", week=" + snapshot.getWeek() + + ", shares=" + snapshot.getOwnedShares().size() + + ", txns=" + snapshot.getTransactions().size()); + try { + saveGameService.saveGame(snapshot); + System.out.println("[auto-save] Wrote save '" + snapshot.getName() + "' to disk."); + } catch (Exception e) { + System.err.println("[auto-save] Failed: " + e.getMessage()); + e.printStackTrace(); + } + }); + // Register all views viewManager.addView(mainMenuView); viewManager.addView(playGameView); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/engine/Exchange.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/engine/Exchange.java index b73f634..dc0d271 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/engine/Exchange.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/engine/Exchange.java @@ -103,6 +103,55 @@ public IntegerProperty weekProperty() { return week; } + /** + * Sets the current week directly. + * + *
+ * Used by the save-loading flow when applying a + * {@link edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame} to the + * exchange. The {@link IntegerProperty} fires a change event so any + * listeners that re-render on week changes refresh themselves. + *
+ * + * @param newWeek the value to set the week to. + * + * @throws IllegalArgumentException if newWeek is below 1. + * */ + public void setWeek(final int newWeek) throws IllegalArgumentException { + if (newWeek < 1) { + throw new IllegalArgumentException("Week must be at least 1!"); + } + week.set(newWeek); + } + + /** + * Resets every known stock on the exchange back to a baseline sales + * price. + * + *+ * Wipes the price history of each stock that has a matching entry + * in {@code baselinePrices} and re-seeds it with the given value. + * Used by the save-loading flow so the exchange the loaded save + * sees matches the baseline the save started from, regardless of + * how the simulation had drifted before. + *
+ * + * @param baselinePrices map of symbol to the price to seed with. + * Symbols that aren't on this exchange are + * skipped. + * */ + public void resetStocksTo(final MapUsed by the save-loading flow when applying a + * {@link edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame} to an + * existing player, to make sure no transactions from the previous + * save remain.
+ * */ + public void clear() { + transactions.clear(); + } + /** * Checks whether the archive is empty. * diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/OwnedShareData.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/OwnedShareData.java new file mode 100644 index 0000000..92aaa30 --- /dev/null +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/OwnedShareData.java @@ -0,0 +1,81 @@ +package edu.ntnu.idi.idatt2003.g40.mappe.model; + +import java.math.BigDecimal; + +/** + * Immutable data record describing a single owned share in a {@link SaveGame}. + * + *+ * Holds just enough information to recreate a {@link Share} when the save is + * loaded: the stock symbol, the quantity owned and the original purchase + * price. The actual {@link Stock} object is looked up on the live + * {@link edu.ntnu.idi.idatt2003.g40.mappe.engine.Exchange} when the save is + * applied. + *
+ * + *+ * Kept as a separate data type (rather than serialising {@link Share} + * directly) so the on-disk format stays decoupled from the runtime model + * and is easy to read/write through the hand-written JSON parser in + * {@link edu.ntnu.idi.idatt2003.g40.mappe.service.SaveGameService}. + *
+ */ +public final class OwnedShareData { + + /** Stock symbol this share is for. */ + private final String symbol; + + /** Quantity of the stock owned. */ + private final BigDecimal quantity; + + /** Price per unit at the time of purchase. */ + private final BigDecimal purchasePrice; + + /** + * Constructor. + * + * @param symbol the stock symbol. + * @param quantity the quantity owned. + * @param purchasePrice the price per unit at purchase time. + * + * @throws IllegalArgumentException if any argument is null. + * */ + public OwnedShareData(final String symbol, + final BigDecimal quantity, + final BigDecimal purchasePrice) + throws IllegalArgumentException { + if (symbol == null || quantity == null || purchasePrice == null) { + throw new IllegalArgumentException("Invalid owned share data!"); + } + this.symbol = symbol; + this.quantity = quantity; + this.purchasePrice = purchasePrice; + } + + /** + * Getter method for the symbol. + * + * @return the stock symbol. + * */ + public String getSymbol() { + return symbol; + } + + /** + * Getter method for the quantity. + * + * @return the quantity owned. + * */ + public BigDecimal getQuantity() { + return quantity; + } + + /** + * Getter method for the purchase price. + * + * @return the price per unit at purchase time. + * */ + public BigDecimal getPurchasePrice() { + return purchasePrice; + } +} diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Player.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Player.java index 9cac71e..7f79db4 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Player.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Player.java @@ -123,6 +123,40 @@ public void withdrawMoney(final BigDecimal amount) { money = money.subtract(amount); } + /** + * Replaces the players current balance with the given value. + * + *+ * Used by the save-loading flow when applying a {@link SaveGame} to + * an existing player. Unlike {@link #addMoney} / {@link #withdrawMoney}, + * this fully overwrites the current value rather than adjusting it. + *
+ * + * @param newMoney the value to set the balance to. + * + * @throws IllegalArgumentException if newMoney is null. + * */ + public void setMoney(final BigDecimal newMoney) throws IllegalArgumentException { + if (newMoney == null) { + throw new IllegalArgumentException("Money cannot be null!"); + } + money = newMoney; + } + + /** + * Re-publishes the players current money and net-worth to the + * {@link FloatProperty} listeners. + * + *+ * Used after the save-loading flow has mutated the player state + * (money, portfolio, archive) so listeners across the UI refresh. + *
+ * */ + public void refreshProperties() { + networthAsFloatProp.setValue(getNetWorth().floatValue()); + moneyAsFloatProp.setValue(money); + } + /** * Returns the players portfolio. * diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Portfolio.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Portfolio.java index 483adb8..0f232bd 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Portfolio.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/Portfolio.java @@ -72,6 +72,18 @@ public ListUsed by the save-loading flow when applying a + * {@link edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame} to an + * existing player, to make sure no shares from the previous save + * remain.
+ * */ + public void clear() { + shares.clear(); + } + /** * Returns an immutable snapshot of all shares whose * stock symbol matches the given symbol. diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/SaveGame.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/SaveGame.java index ff00e77..9dc97ec 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/SaveGame.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/SaveGame.java @@ -1,18 +1,29 @@ package edu.ntnu.idi.idatt2003.g40.mappe.model; +import java.util.Collections; +import java.util.List; + /** * Represents one save game entry. * *- * Holds the display name, current balance, starting capital and - * (optionally) the path to a custom stock data file for a single - * saved game. + * Holds the display name, current balance, starting capital, + * (optionally) the path to a custom stock data file, the in-game week, + * the list of owned shares and the list of committed transactions for a + * single saved game. *
* ** When {@link #getStockDataPath()} returns {@code null} the save is * expected to be loaded with the default bundled stock data file. *
+ * + *+ * Older saves on disk may not contain {@link #getWeek()}, + * {@link #getOwnedShares()} or {@link #getTransactions()}; in that case + * the convenience constructor defaults week to 1 and the two lists to + * empty, so the save still loads cleanly. + *
*/ public class SaveGame { @@ -31,6 +42,15 @@ public class SaveGame { * */ private final String stockDataPath; + /** In-game week when the save was last written. */ + private final int week; + + /** Shares the player owned when the save was last written. */ + private final List+ * Week defaults to 1, owned shares and transactions default to empty + * lists. This keeps the create-game flow unchanged while older save + * files (which don't yet contain gameplay data) load with sensible + * defaults. + *
+ * + * @param name the display name of the save. + * @param balance the current balance value. + * @param startingCapital the starting capital chosen on creation. + * @param stockDataPath absolute path to a custom stock data file, + * or {@code null} to use the default file. + * */ + public SaveGame(final String name, + final double balance, + final double startingCapital, + final String stockDataPath) { + this(name, balance, startingCapital, stockDataPath, + 1, Collections.emptyList(), Collections.emptyList()); } /** * Convenience constructor matching the legacy "name + balance" format. * *- * Starting capital is defaulted to the balance value and + * Starting capital is defaulted to the balance value, * {@code stockDataPath} is left {@code null} so the default stock - * data file is used. + * data file is used, week defaults to 1 and the two gameplay lists + * default to empty. *
* * @param name the display name of the save. * @param balance the current balance value. */ public SaveGame(final String name, final double balance) { - this(name, balance, balance, null); + this(name, balance, balance, null, + 1, Collections.emptyList(), Collections.emptyList()); } /** @@ -102,4 +164,31 @@ public double getStartingCapital() { public String getStockDataPath() { return stockDataPath; } + + /** + * Getter method for the in-game week. + * + * @return the week the save was last written at. + * */ + public int getWeek() { + return week; + } + + /** + * Getter method for the owned shares. + * + * @return an immutable list of {@link OwnedShareData} entries. + * */ + public List+ * Used by the save-loading flow so each save can start the simulation + * from a known baseline rather than inheriting the drift accumulated + * by the previous session. + *
+ * + * @param baseline the price to seed the history with. + * + * @throws IllegalArgumentException if baseline is null or zero. + * */ + public void resetPrices(final BigDecimal baseline) throws IllegalArgumentException { + if (baseline == null || baseline.intValue() == 0) { + throw new IllegalArgumentException( + "Invalid baseline price for stock: " + getSymbol()); + } + prices.clear(); + prices.add(baseline); + fortune = 0; + } + /** * Returns list of all prices for this stock. * diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/TransactionData.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/TransactionData.java new file mode 100644 index 0000000..45f1bde --- /dev/null +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/model/TransactionData.java @@ -0,0 +1,115 @@ +package edu.ntnu.idi.idatt2003.g40.mappe.model; + +import edu.ntnu.idi.idatt2003.g40.mappe.service.TransactionType; +import java.math.BigDecimal; + +/** + * Immutable data record describing a single committed transaction in a + * {@link SaveGame}. + * + *+ * Holds the {@link TransactionType} (purchase or sale), the symbol and + * quantity traded, the price per unit at the time of the transaction, and + * the in-game week it took place. Used by the + * {@link edu.ntnu.idi.idatt2003.g40.mappe.service.SaveGameService} to + * persist the {@link edu.ntnu.idi.idatt2003.g40.mappe.engine.TransactionArchive} + * across sessions. + *
+ * + *+ * Kept as a separate data type (rather than serialising + * {@link Transaction} subclasses directly) so the on-disk format stays + * decoupled from the runtime model and is easy to read/write through the + * hand-written JSON parser. + *
+ */ +public final class TransactionData { + + /** Type of transaction (purchase or sale). */ + private final TransactionType type; + + /** Stock symbol this transaction is for. */ + private final String symbol; + + /** Quantity of the stock involved in the transaction. */ + private final BigDecimal quantity; + + /** Price per unit at the time of the transaction. */ + private final BigDecimal price; + + /** In-game week this transaction took place during. */ + private final int week; + + /** + * Constructor. + * + * @param type the transaction type. + * @param symbol the stock symbol. + * @param quantity the quantity traded. + * @param price the price per unit at the time of the transaction. + * @param week the in-game week the transaction took place. + * + * @throws IllegalArgumentException if any reference argument is null. + * */ + public TransactionData(final TransactionType type, + final String symbol, + final BigDecimal quantity, + final BigDecimal price, + final int week) + throws IllegalArgumentException { + if (type == null || symbol == null + || quantity == null || price == null) { + throw new IllegalArgumentException("Invalid transaction data!"); + } + this.type = type; + this.symbol = symbol; + this.quantity = quantity; + this.price = price; + this.week = week; + } + + /** + * Getter method for the transaction type. + * + * @return the {@link TransactionType}. + * */ + public TransactionType getType() { + return type; + } + + /** + * Getter method for the symbol. + * + * @return the stock symbol. + * */ + public String getSymbol() { + return symbol; + } + + /** + * Getter method for the quantity. + * + * @return the quantity traded. + * */ + public BigDecimal getQuantity() { + return quantity; + } + + /** + * Getter method for the price. + * + * @return the price per unit at the time of the transaction. + * */ + public BigDecimal getPrice() { + return price; + } + + /** + * Getter method for the week. + * + * @return the in-game week the transaction took place. + * */ + public int getWeek() { + return week; + } +} diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/GameStateLoader.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/GameStateLoader.java new file mode 100644 index 0000000..8cbd29e --- /dev/null +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/GameStateLoader.java @@ -0,0 +1,302 @@ +package edu.ntnu.idi.idatt2003.g40.mappe.service; + +import edu.ntnu.idi.idatt2003.g40.mappe.engine.Exchange; +import edu.ntnu.idi.idatt2003.g40.mappe.engine.TransactionArchive; +import edu.ntnu.idi.idatt2003.g40.mappe.model.OwnedShareData; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Player; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Portfolio; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Purchase; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Sale; +import edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Share; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Stock; +import edu.ntnu.idi.idatt2003.g40.mappe.model.Transaction; +import edu.ntnu.idi.idatt2003.g40.mappe.model.TransactionData; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventData; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventManager; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventPublisher; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventSubscriber; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventType; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Service that applies a {@link SaveGame} to the active {@link Player} + * and {@link Exchange}. + * + *+ * Listens for {@link EventType#LOAD_SAVE} events. When one fires the + * service: + *
+ *+ * Implements both {@link EventSubscriber} (to receive + * {@code LOAD_SAVE}) and {@link EventPublisher} (to emit + * {@code STATE_RESET}). Tracks the {@link SaveGame} most recently + * loaded so callers (the auto-save flow on quit) can ask which save + * is currently active. + *
+ */ +public final class GameStateLoader + implements EventSubscriber, EventPublisher { + + /** Active {@link Player} instance being mutated on load. */ + private final Player player; + + /** Active {@link Exchange} instance being mutated on load. */ + private final Exchange exchange; + + /** Used to publish {@link EventType#STATE_RESET} after a load. */ + private final EventManager eventManager; + + /** + * Snapshot of every stock's price at construction time. + * + *+ * Captured so each save load can rewind the exchange to a known + * baseline regardless of how the simulation has drifted in this + * session. + *
+ * */ + private final MapUsed by the auto-save flow so it can write back to the same + * file the player opened.
+ * */ + private SaveGame activeSave; + + /** + * Constructor. + * + * @param player the {@link Player} to apply saves to. + * @param exchange the {@link Exchange} to apply saves to. + * @param stockList the list of stocks from which to capture a + * baseline price snapshot. + * @param eventManager the active {@link EventManager}; the loader + * subscribes to {@link EventType#LOAD_SAVE} and + * publishes {@link EventType#STATE_RESET} + * through it. + * + * @throws IllegalArgumentException if any argument is null. + * */ + public GameStateLoader(final Player player, + final Exchange exchange, + final List+ * Used by the auto-save flow when the player quits the in-game view + * back to the main menu. Returns {@code null} if no save is active + * (i.e. the player hasn't opened a save in this session). + *
+ * + * @return a snapshot {@link SaveGame}, or {@code null}. + * */ + public SaveGame snapshotActiveSave() { + if (activeSave == null) { + return null; + } + List+ * Resets stock prices to the captured baseline, then wipes and + * re-seeds the player's money, portfolio and transaction archive + * from the save, and finally sets the in-game week. + *
+ * + * @param save the {@link SaveGame} to apply. + * */ + private void applySave(final SaveGame save) { + System.out.println("[loader] applySave: name=" + save.getName() + + ", balance=" + save.getBalance() + + ", week=" + save.getWeek() + + ", shares=" + save.getOwnedShares().size() + + ", txns=" + save.getTransactions().size()); + + // 1. Roll the exchange's prices back to the captured baseline and + // the exchange's week back to 1, so the simulation starts + // fresh. + exchange.resetStocksTo(baselinePrices); + exchange.setWeek(1); + + // 2. Reset the player to an empty state with the saved starting + // capital. We use starting capital here (rather than the + // final saved balance) so the per-week net-worth samples + // collected by widget listeners during the advances below + // have a meaningful baseline. + Portfolio portfolio = player.getPortfolio(); + portfolio.clear(); + TransactionArchive archive = player.getTransactionArchive(); + archive.clear(); + player.setMoney(BigDecimal.valueOf(save.getStartingCapital())); + player.refreshProperties(); + + // 3. Advance the exchange (save.getWeek() - 1) times so the + // stock price graphs get a real history, and so widgets that + // track per-week net-worth (the financial summary and the + // stats screen) accumulate chart points via their existing + // weekProperty listeners. + int targetWeek = Math.max(1, save.getWeek()); + while (exchange.getWeek() < targetWeek) { + exchange.advance(); + } + + // 4. Re-apply the saved owned shares (skipping unknown symbols + // instead of throwing - keeps the load robust against stock + // files that have diverged from the save). + for (OwnedShareData od : save.getOwnedShares()) { + if (!exchange.hasStock(od.getSymbol())) { + System.err.println("Skipping unknown stock from save: " + + od.getSymbol()); + continue; + } + Stock stock = exchange.getStock(od.getSymbol()); + portfolio.addShare(new Share( + stock, od.getQuantity(), od.getPurchasePrice())); + } + + // 5. Re-apply the saved transactions. We rebuild the original + // Purchase / Sale objects (with their share + calculator) + // directly into the archive, rather than re-running them + // through the player's transaction handler, so the player's + // balance is not mutated again here. + for (TransactionData td : save.getTransactions()) { + if (!exchange.hasStock(td.getSymbol())) { + System.err.println("Skipping transaction with unknown stock: " + + td.getSymbol()); + continue; + } + Stock stock = exchange.getStock(td.getSymbol()); + Share share = new Share(stock, td.getQuantity(), td.getPrice()); + TransactionCalculator calculator = (td.getType() == TransactionType.SALE) + ? new SaleCalculator(share) + : new PurchaseCalculator(share); + Transaction transaction = (td.getType() == TransactionType.SALE) + ? new Sale(share, td.getWeek(), calculator) + : new Purchase(share, td.getWeek(), calculator); + archive.add(transaction); + } + + // 6. Overwrite the balance with the saved value now that the + // history has been built up. + player.setMoney(BigDecimal.valueOf(save.getBalance())); + + // 7. Re-publish the float properties so the final loaded + // balance is visible to all listeners. + player.refreshProperties(); + + this.activeSave = save; + } + + /** + * Captures the current sales price of every stock provided, so saves + * can be loaded against a stable baseline regardless of subsequent + * price drift during the session. + * */ + private static Map+ * The {@code week}, {@code ownedShares} and {@code transactions} + * fields are optional - old saves without them load with sensible + * defaults (week 1, empty lists). + *
+ * + ** The service exposes both a "load all saves" entry point (used by * {@link edu.ntnu.idi.idatt2003.g40.mappe.view.playgame.PlayGameView}) - * and a "save one" entry point (used by the create-game flow). The - * JSON parsing logic is intentionally minimal and tailored to this - * format — invalid files are skipped rather than throwing. + * and a "save one" entry point (used by the create-game flow and the + * auto-save flow). The JSON parsing logic is intentionally minimal and + * tailored to this format - invalid files are skipped rather than + * throwing. *
*/ public class SaveGameService { @@ -188,7 +206,9 @@ public SaveGame loadSaveFromFile(final Path file) { * ** Returns {@code null} for files that don't contain a valid save - * record (missing required fields or malformed JSON). + * record (missing required fields or malformed JSON). Old saves + * that don't have week / ownedShares / transactions still parse + * cleanly, with those fields defaulted (week 1, empty lists). *
* * @param file path to the file to parse. @@ -219,7 +239,25 @@ private SaveGame parseFile(final Path file) { stockDataPath = null; } - return new SaveGame(name, balance, startingCapital, stockDataPath); + // Week - defaults to 1 for older saves that didn't store it. + int week = 1; + if (fields.containsKey("week") && !fields.get("week").isEmpty()) { + week = Integer.parseInt(fields.get("week")); + if (week < 1) { + week = 1; + } + } + + // The flat parser can't represent arrays in the result map, so + // the array bodies are pulled straight out of the raw content + // with extractArrayBody and then parsed object-by-object. + ListReturns {@code "[]"} for empty lists, and a pretty-printed + * array for non-empty lists - one object per line, indented to + * four spaces.
+ */ + private String ownedSharesToJson(final ListReturns {@code "[]"} for empty lists, and a pretty-printed + * array for non-empty lists - one object per line, indented to + * four spaces.
+ */ + private String transactionsToJson(final List+ * Given the raw content {@code {"name": "x", "ownedShares": [ ... ]}} + * and a field name {@code "ownedShares"}, returns the substring + * between the opening {@code [} and the matching closing {@code ]}. + *
+ * + *+ * Returns {@code null} if the field is not present, or if the array + * is malformed (no closing bracket). Returns an empty string for an + * empty array. + *
+ * + *+ * Brackets inside quoted strings are ignored, so a string value like + * {@code "weird]name"} won't confuse the matcher. + *
+ * + * @param content the raw file content. + * @param fieldName the JSON field name to look for. + * @return the body of the matching array, or {@code null}. + * */ + private String extractArrayBody(final String content, + final String fieldName) { + if (content == null) { + return null; + } + String needle = "\"" + fieldName + "\""; + int keyIdx = content.indexOf(needle); + if (keyIdx < 0) { + return null; + } + int colonIdx = content.indexOf(':', keyIdx + needle.length()); + if (colonIdx < 0) { + return null; + } + int openIdx = colonIdx + 1; + while (openIdx < content.length() + && Character.isWhitespace(content.charAt(openIdx))) { + openIdx++; + } + if (openIdx >= content.length() || content.charAt(openIdx) != '[') { + return null; + } + + int depth = 1; + boolean inString = false; + int i = openIdx + 1; + while (i < content.length()) { + char c = content.charAt(i); + if (inString) { + if (c == '\\' && i + 1 < content.length()) { + i += 2; + continue; + } + if (c == '"') { + inString = false; + } + } else { + if (c == '"') { + inString = true; + } else if (c == '[') { + depth++; + } else if (c == ']') { + depth--; + if (depth == 0) { + return content.substring(openIdx + 1, i); + } + } + } + i++; + } + return null; + } + + /** + * Splits a JSON array body into the substrings of its inner objects. + * + *+ * Tracks bracket and quote nesting so that commas inside the inner + * objects (or inside quoted strings) don't split the body + * incorrectly. Returns the substrings of each top-level + * {@code {...}} found. + *
+ * + * @param body the substring between {@code [} and {@code ]}. + * @return list of object substrings, or an empty list. + * */ + private ListReturns an empty list for a {@code null}/empty body. Objects + * that fail to parse are silently skipped so a single malformed + * entry doesn't break the rest of the save.
+ * + * @param body the raw substring between {@code [} and {@code ]}. + * @return parsed list, never {@code null}. + * */ + private ListReturns an empty list for a {@code null}/empty body. Objects + * that fail to parse are silently skipped so a single malformed + * entry doesn't break the rest of the save.
+ * + * @param body the raw substring between {@code [} and {@code ]}. + * @return parsed list, never {@code null}. + * */ + private List- * Only supports string, number, boolean and null values, which is - * everything {@link SaveGame} needs. Returns {@code null} if the - * content can't be parsed. + * Only supports string, number, boolean and null values for the + * top-level entries (which is everything {@link SaveGame} and + * {@link OwnedShareData} / {@link TransactionData} need). Nested + * arrays and objects are skipped past with an empty-string value, + * since the array contents are read separately via + * {@link #extractArrayBody}. Returns {@code null} if the content + * can't be parsed. *
* *
@@ -335,6 +662,17 @@ private Map
+ * Used by {@link #parseFlatJsonObject} to skip past nested arrays
+ * and objects whose content isn't needed at the top level (top-level
+ * arrays are read separately by {@link #extractArrayBody}).
+ * The payload is the {@link edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame}
+ * to apply. Handled by
+ * {@link edu.ntnu.idi.idatt2003.g40.mappe.service.GameStateLoader},
+ * which mutates the live player and exchange to match the save and
+ * then publishes a {@link #STATE_RESET} event so widgets refresh. Widget controllers that accumulate per-week history (chart
+ * data) listen for this event and clear that history, so the
+ * advance-replay during the load can rebuild it cleanly without
+ * mixing samples from the previous session. Widget controllers that maintain their own derived state
+ * (chart history, transaction-week selectors, etc.) listen to this
+ * event so they can rebuild that state from the fresh player /
+ * exchange. The payload is the {@link
+ * edu.ntnu.idi.idatt2003.g40.mappe.model.SaveGame} that was loaded
+ * but most listeners don't inspect it.
+ * Used to defeat the staleness of the {@link SaveGame} instance
+ * bound to the clicked row in {@link PlayGameView}: between when
+ * {@link #refresh()} last ran and the click, the auto-save flow may
+ * have written newer content to disk.
+ * Rebuilds the stock list (so the "owned" counts shown beside
+ * each stock reflect the newly loaded portfolio), re-selects the
+ * first stock as the current focus, and redraws the price graph. On {@link EventType#PRE_LOAD_SAVE} the chart history is
+ * cleared so the advance-replay during the load can rebuild it
+ * cleanly. On {@link EventType#STATE_RESET} the final balance
+ * point is replaced with the actual loaded net worth, and the
+ * week / balance labels are pushed into the view.
+ * The week-property and net-worth-property listeners will trigger + * renderStocks() when the save loader mutates state, but we also + * re-render here explicitly so the grid is correct even if the new + * state happens to match the old listener values. + *
+ * */ + @Override + publicOn {@link EventType#PRE_LOAD_SAVE} the balance history is + * cleared so the advance-replay during the load can rebuild it + * cleanly. On {@link EventType#STATE_RESET} the final entry is + * overwritten with the actual loaded net worth, then a snapshot + * is pushed.
+ * */ + @Override + publicUsed by the application to auto-save the active save before + * the player goes back to the play-game list. Defaults to a no-op + * so the controller stays usable when no hook is installed.
+ * */ + private Runnable onQuitToMainMenu = () -> { }; + /** * {@inheritDoc}. @@ -60,7 +70,10 @@ public TopBarController(final TopBarView viewElement, final EventManager eventMa @Override protected void initInteractions() { - getViewElement().setOnAction(TopBarActions.EXIT, () -> changeScene(ViewEnum.MAIN_MENU)); + getViewElement().setOnAction(TopBarActions.EXIT, () -> { + onQuitToMainMenu.run(); + changeScene(ViewEnum.MAIN_MENU); + }); getViewElement().setOnAction(TopBarActions.STATS, () -> changeScene(ViewEnum.MAIN_MENU)); @@ -116,6 +129,7 @@ public void setMarketIntegration(final ConsumerUsed by the application to auto-save the active save before + * the player returns to the play-game list, so progress made in + * this session is persisted without an explicit save action.
+ * + * @param onQuit runnable invoked just before the scene change. + * */ + public void setOnQuitToMainMenu(final Runnable onQuit) { + this.onQuitToMainMenu = (onQuit != null) ? onQuit : () -> { }; + } } \ No newline at end of file diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/transactions/TransactionsController.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/transactions/TransactionsController.java index 7179630..a034fdb 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/transactions/TransactionsController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/transactions/TransactionsController.java @@ -3,7 +3,10 @@ import edu.ntnu.idi.idatt2003.g40.mappe.engine.TransactionArchive; import edu.ntnu.idi.idatt2003.g40.mappe.model.Sale; import edu.ntnu.idi.idatt2003.g40.mappe.model.Transaction; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventData; import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventManager; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventSubscriber; +import edu.ntnu.idi.idatt2003.g40.mappe.service.event.EventType; import edu.ntnu.idi.idatt2003.g40.mappe.view.ViewController; import java.util.List; @@ -12,7 +15,8 @@ * *extends {@link ViewController}
* */ -public class TransactionsController extends ViewControllerDelegates to {@link #refresh()} which rebuilds the week selector + * options from the freshly loaded archive and re-renders the cards + * for the current selection.
+ * */ + @Override + public