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 cf63d80..afc0b11 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 @@ -150,6 +150,23 @@ public void setNetWorthHistory(final List history) { } } + /** + * Appends a new net-worth sample to this player's history. + * + *

Used by the simulation driver to record one data point per + * advanced week so the resulting time series can be persisted into a + * {@link SaveGame} and replayed by the stats dashboard after a + * reload. Null values are ignored.

+ * + * @param sample the net-worth value to record. + */ + public void recordNetWorthSample(final BigDecimal sample) { + if (sample == null) { + return; + } + this.netWorthHistory.add(sample); + } + /** * Adds money to the players balance. * 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 index 874f6ec..6f67aa5 100644 --- 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 @@ -175,29 +175,54 @@ public void invoke(final EventData data, final EventManager manager) { * @param save save to apply. * */ private void applySave(final SaveGame save) { - Map activeBaseline; - if (save.getExchangeStocks() != null && !save.getExchangeStocks().isEmpty()) { - activeBaseline = captureBaseline(save.getExchangeStocks()); + // The save snapshot now carries the full price history for every + // stock, so the simplest and most deterministic restore is to drop + // the lagged exchange's stock pool entirely and replace it with the + // saved one. That preserves both the current price *and* the + // historical chart line, with no random replay between save and + // load. + // + // For old save files that only contain a current price per stock + // (no "prices" array, no "netWorthHistory" array), we still need a + // sensible fallback. In that case we keep the previous behaviour + // of resetting the live exchange to the baseline and advancing the + // simulation forward week-by-week, accepting that the resulting + // chart will be "freshly simulated" because the source data simply + // doesn't contain the history needed to be more accurate. + boolean haveSavedStocks = + save.getExchangeStocks() != null && !save.getExchangeStocks().isEmpty(); + boolean haveSavedHistory = haveSavedStocks + && save.getExchangeStocks().getFirst().getHistoricalPrices().size() > 1; + if (haveSavedStocks && haveSavedHistory) { + // Modern save: snapshot has full price history per stock. + // Replace pool wholesale, set the week directly, do NOT advance. this.exchange.updateStockPool(save.getExchangeStocks()); + exchange.setWeek(Math.max(1, save.getWeek())); } else { - activeBaseline = this.baselinePrices; - } + // Legacy save: no per-stock history available. Reset to a + // baseline and advance the simulation forward to the saved week + // so the rest of the game state at least has the right number + // of price ticks behind it. + Map activeBaseline = haveSavedStocks + ? captureBaseline(save.getExchangeStocks()) + : this.baselinePrices; + if (haveSavedStocks) { + this.exchange.updateStockPool(save.getExchangeStocks()); + } + exchange.resetStocksTo(activeBaseline); + exchange.setWeek(1); - exchange.resetStocksTo(activeBaseline); - exchange.setWeek(1); + int targetWeek = Math.max(1, save.getWeek()); + while (exchange.getWeek() < targetWeek) { + exchange.advance(); + } + } Portfolio portfolio = player.getPortfolio(); portfolio.clear(); TransactionArchive archive = player.getTransactionArchive(); archive.clear(); - player.setMoney(BigDecimal.valueOf(save.getStartingCapital())); - player.refreshProperties(); - - int targetWeek = Math.max(1, save.getWeek()); - while (exchange.getWeek() < targetWeek) { - exchange.advance(); - } for (OwnedShareData od : save.getOwnedShares()) { if (!exchange.hasStock(od.getSymbol())) { @@ -226,10 +251,15 @@ private void applySave(final SaveGame save) { player.setMoney(BigDecimal.valueOf(save.getBalance())); - if (save.getNetWorthHistory() != null) { + if (save.getNetWorthHistory() != null && !save.getNetWorthHistory().isEmpty()) { player.setNetWorthHistory(save.getNetWorthHistory()); } else { - player.setNetWorthHistory(new ArrayList<>()); + // No recorded history available - seed a minimal two-point + // history so the chart still has something to render. + List seed = new ArrayList<>(); + seed.add(BigDecimal.valueOf(save.getStartingCapital())); + seed.add(player.getNetWorth()); + player.setNetWorthHistory(seed); } player.refreshProperties(); diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameService.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameService.java index cf86ca5..8f579ba 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameService.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/service/SaveGameService.java @@ -255,7 +255,36 @@ private SaveGame parseFile(final Path file) { if (sym != null && nm != null && prcStr != null) { try { - exchangeStocks.add(new Stock(sym, nm, new BigDecimal(prcStr))); + // Read full price history if present, otherwise fall back + // to the single "price" field for backwards compatibility + // with old save files. + List history = + parseNumberArray(extractArrayBody(objectStr, "prices")); + BigDecimal seed; + if (history != null && !history.isEmpty()) { + seed = history.getFirst(); + } else { + seed = new BigDecimal(prcStr); + } + + Stock stock = new Stock(sym, nm, seed); + if (history != null && history.size() > 1) { + for (int idx = 1; idx < history.size(); idx++) { + stock.addNewSalesPrice(history.get(idx)); + } + } + + // Restore fortune if present. + String fortuneStr = stockFields.get("fortune"); + if (fortuneStr != null && !fortuneStr.isEmpty()) { + try { + stock.setFortune(Double.parseDouble(fortuneStr)); + } catch (NumberFormatException ignored) { + // Default fortune of 0 will be retained. + } + } + + exchangeStocks.add(stock); } catch (Exception e) { // Skip individual invalid elements. } @@ -263,8 +292,14 @@ private SaveGame parseFile(final Path file) { } } + List netWorthHistory = + parseNumberArray(extractArrayBody(content, "netWorthHistory")); + if (netWorthHistory == null) { + netWorthHistory = Collections.emptyList(); + } + return new SaveGame(name, balance, startingCapital, stockDataPath, - week, ownedShares, transactions, exchangeStocks); + week, ownedShares, transactions, exchangeStocks, netWorthHistory); } catch (IOException | NumberFormatException e) { System.err.println("Skipping invalid save file " + file.getFileName() + ": " + e.getMessage()); @@ -301,11 +336,21 @@ private String toJson(final SaveGame save) { sb.append(" \"ownedShares\": ").append(ownedSharesToJson(save.getOwnedShares())).append(",\n"); sb.append(" \"transactions\": ").append(transactionsToJson(save.getTransactions())).append(",\n"); - sb.append(" \"stocks\": ").append(stocksToJson(save.getExchangeStocks())).append("\n"); + sb.append(" \"stocks\": ").append(stocksToJson(save.getExchangeStocks())).append(",\n"); + sb.append(" \"netWorthHistory\": ").append(numberListToJson(save.getNetWorthHistory())).append("\n"); sb.append("}\n"); return sb.toString(); } + /** + * Serialises a list of {@link Stock} entries to a JSON array. + * + *

Each stock writes its current price and its full price history + * so the per-stock charts remain consistent across save/load cycles. The + * {@code price} field is kept for backwards compatibility with old saves + * and external readers; loaders that recognise {@code prices} should + * prefer the array.

+ */ private String stocksToJson(final List stocks) { if (stocks == null || stocks.isEmpty()) { return "[]"; @@ -317,6 +362,8 @@ private String stocksToJson(final List stocks) { sb.append(" { \"symbol\": ").append(quote(s.getSymbol())) .append(", \"name\": ").append(quote(s.getCompany())) .append(", \"price\": ").append(s.getSalesPrice().toPlainString()) + .append(", \"prices\": ").append(numberListToJson(s.getHistoricalPrices())) + .append(", \"fortune\": ").append(BigDecimal.valueOf(s.getFortune()).toPlainString()) .append(" }"); if (i < stocks.size() - 1) { sb.append(","); @@ -327,6 +374,29 @@ private String stocksToJson(final List stocks) { return sb.toString(); } + /** + * Serialises a list of {@link BigDecimal} values to a compact JSON array. + * + *

Returns {@code "[]"} for null/empty lists. Uses {@link BigDecimal#toPlainString()} + * to avoid scientific notation so the file remains diff-friendly and + * round-trippable.

+ */ + private String numberListToJson(final List values) { + if (values == null || values.isEmpty()) { + return "[]"; + } + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(values.get(i).toPlainString()); + } + sb.append("]"); + return sb.toString(); + } + /** * Serialises a list of {@link OwnedShareData} entries to a JSON array. * @@ -561,6 +631,44 @@ private List parseOwnedSharesArray(final String body) { return result; } + /** + * Parses the body of a flat JSON number array into a list of + * {@link BigDecimal} values. + * + *

Accepts a comma-separated list of numeric tokens (with optional + * surrounding whitespace and an optional minus sign). Returns + * {@code null} when the supplied body is {@code null} (so callers can + * distinguish "field absent" from "field empty"). Returns an empty + * list when the body is empty or contains only whitespace. Tokens + * that fail {@link BigDecimal} parsing are silently skipped so a + * single malformed entry doesn't break the rest of the array.

+ * + * @param body the raw substring between {@code [} and {@code ]}, or {@code null}. + * @return parsed list of {@link BigDecimal} values, or {@code null} if {@code body} is null. + */ + private List parseNumberArray(final String body) { + if (body == null) { + return null; + } + List result = new ArrayList<>(); + String trimmed = body.trim(); + if (trimmed.isEmpty()) { + return result; + } + for (String raw : trimmed.split(",")) { + String token = raw.trim(); + if (token.isEmpty()) { + continue; + } + try { + result.add(new BigDecimal(token)); + } catch (NumberFormatException e) { + System.err.println("Skipping malformed numeric entry: " + token); + } + } + return result; + } + /** * Parses the body of a {@code transactions} array into a list of * {@link TransactionData}. diff --git a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/stats/StatsController.java b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/stats/StatsController.java index ac4d39e..52e23bc 100644 --- a/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/stats/StatsController.java +++ b/src/main/java/edu/ntnu/idi/idatt2003/g40/mappe/view/widgets/stats/StatsController.java @@ -86,13 +86,24 @@ protected void initInteractions() { // constructor, so we must initialize the list here. balanceHistory = new ArrayList<>(); - // Seed the history with the players starting balance so the chart - // and the all-time P&L KPI have a meaningful baseline from week 1. - balanceHistory.add(player.getStartingMoney()); + // Seed the chart history from the players recorded net-worth + // history if present (eg. after a save load), otherwise from + // starting money so the chart and the all-time P&L KPI have a + // meaningful baseline from week 1. + if (player.getNetWorthHistory() != null && !player.getNetWorthHistory().isEmpty()) { + balanceHistory.addAll(player.getNetWorthHistory()); + } else { + balanceHistory.add(player.getStartingMoney()); + } pushSnapshot(); exchange.weekProperty().addListener((observable, o, n) -> { - balanceHistory.add(player.getNetWorth()); + BigDecimal sample = player.getNetWorth(); + balanceHistory.add(sample); + // Also record the sample on the player so the snapshot taken by + // GameStateLoader (and persisted by SaveGameService) captures the + // full time series for the next load. + player.recordNetWorthSample(sample); pushSnapshot(); });