diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index d3f0a12..6f58cb5 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -23,6 +23,10 @@ first. * Mods marked **modifies groove.exe** add code to the game program of LlamaMod's copy. This is safe, but on Windows some antivirus programs flag the changed file – see [Troubleshooting](20-troubleshooting.md). +* If LlamaMod was closed in the middle of an Apply, it repairs its game copy at the next start + and tells you whether it finished that Apply or went back to the previous mod selection. +* Two LlamaMod windows (or the level editor) share one mod selection: a window shows the other's + changes as soon as you click into it. ![Several mods combined in the village](img/mod_all.jpg) @@ -97,7 +101,7 @@ Play starts the game straight in the chosen level – no logos, no menus, no wor | Save slot | 1 – 3 | 3 | The save game used for lives and progress. The game also saves into it. | | Skip startup logos | on / off | on | Also skips the logo movies and the title screen. | | Escape quits the game | on / off | off | Escape ends the game at once instead of opening the pause menu. | -| Player start | text | (empty) | Leave empty for the level's own start. Advanced: "x, y, z[, angle]" in cells moves the start. | +| Player start | text | (empty) | Leave empty for the level's own start. Advanced: "x, y, z[, angle]" in cells moves the start; "… @ world/chapter" uses it only in that level. | #### Intro skip diff --git a/notes/bug-review-launcher.md b/notes/bug-review-launcher.md index 2264eb1..d06b4df 100644 --- a/notes/bug-review-launcher.md +++ b/notes/bug-review-launcher.md @@ -84,7 +84,11 @@ Test World with all mods; boot-to-level + all exe mods (River loading card); pra on-demand files are fragile; the wizard could warn when the chosen folder is under `%OneDrive%`. * ~~**Interrupted apply**~~ – fixed 2026-11-07: `cd-active.new.complete` marker + `ModManager.recoverInterruptedApply()` (GUI start, Play, `--launch`), see llamamod-app.md. + Follow-up 2026-11-07: the GUI tells the user once what was repaired and retries when another + window held the apply lock (was silently skipped). * ~~A `config.json` saved by a stale window overwrites activeMods/gameExeSha256~~ – fixed 2026-11-07: field-level merge-on-save under `.config.lock` (`Config.save`). + Follow-up: the window rebuilds its Mods/Settings tabs after a merge or when activated with a + changed config.json (`Config.refresh`), so its check boxes no longer show stale values. * ~~Old Wine versions are not detected~~ – 2026-11-07: `wine --version` in Settings, warning below Wine 5.0 (Settings in red, once before Play); missing Wine gives a clear message. diff --git a/notes/img/app/interrupted-apply-notice.jpg b/notes/img/app/interrupted-apply-notice.jpg new file mode 100644 index 0000000..0343d1f Binary files /dev/null and b/notes/img/app/interrupted-apply-notice.jpg differ diff --git a/notes/img/app/saves-min-width.jpg b/notes/img/app/saves-min-width.jpg new file mode 100644 index 0000000..fdc064e Binary files /dev/null and b/notes/img/app/saves-min-width.jpg differ diff --git a/notes/img/app/settings-min-width.jpg b/notes/img/app/settings-min-width.jpg new file mode 100644 index 0000000..608b16b Binary files /dev/null and b/notes/img/app/settings-min-width.jpg differ diff --git a/notes/img/app/two-windows-after.jpg b/notes/img/app/two-windows-after.jpg new file mode 100644 index 0000000..ecdb1ef Binary files /dev/null and b/notes/img/app/two-windows-after.jpg differ diff --git a/notes/img/app/two-windows-before.jpg b/notes/img/app/two-windows-before.jpg new file mode 100644 index 0000000..00d2942 Binary files /dev/null and b/notes/img/app/two-windows-before.jpg differ diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index 29fd728..25ee282 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -73,6 +73,15 @@ instance last loaded/saved: fields it did not change keep what another window/CL meanwhile (mod options merge per mod and key; unknown keys of newer versions are kept); a field changed on both sides = last writer wins. The object then holds the merged values. A `new Config()` that never touched a file overwrites everything (installer, tests). +`save` returns true when the merge pulled in values written elsewhere; `Config.refresh(file)` does +the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes +when its window is activated and after a save that pulled something in, then rebuilds the Mods and +Settings tabs (status "Took over changes made in another LlamaMod window."), so a stale window's +next click cannot undo another window's selection. +Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left +window after a click into it), `img/app/interrupted-apply-notice.jpg`, `img/app/saves-min-width.jpg`, +`img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the +Settings tab tracks the viewport width, the header subtitle is shortened in the middle before the buttons). ## First start @@ -157,7 +166,12 @@ after the swap. If the app is killed between the two renames, `ModManager.recove (GUI start, Play, CLI `--launch`) moves `cd-active.new` in when its marker matches config.json's activeKey (exe and config are only written after the swap, so they still describe the previous selection), otherwise moves `cd-active.old` back; without either nothing is touched (Play then -restores the original as before). Applying (and restoring) +restores the original as before). It returns a `Recovery`: NONE, FINISHED (new folder moved in), +RESTORED (old folder back), BUSY (another window/CLI holds `.apply.lock`: `BusyException`), FAILED. +The launcher shows FINISHED/RESTORED once (status line + small dialog: "The last apply was +interrupted; LlamaMod finished it." / "…restored the previous mod selection."); after BUSY it checks +again every 3 s and on window activation while idle, and Play reports the lock instead of starting +(`ui/RecoveryNotice`). CLI `--launch` prints the same texts. Applying (and restoring) is refused while the game runs (`GameLauncher.isRunning()`) and holds an exclusive lock on `/.apply.lock` (a second window/CLI/editor gets "Another LlamaMod window or command is applying mods"); every run is logged to `logs/apply.log`. diff --git a/notes/mod-boottolevel.md b/notes/mod-boottolevel.md index 046c6aa..c62f5fd 100644 --- a/notes/mod-boottolevel.md +++ b/notes/mod-boottolevel.md @@ -10,7 +10,16 @@ no world map. Used by the level editor's Play button (details, offsets and tests | `slot` | 3 | save slot the game plays with (new game if empty) | | `logos` | on | skip logo movies and title screen (skipped if intro-skip already patched them) | | `quitOnEscape` | off | Escape (pause) ends the game at once through the normal exit path | -| `start` | empty | "x, y, z[, yaw]" in cells: moves the level's player start (WAD rebuild) | +| `start` | empty | "x, y, z[, yaw][ @ world/chapter]" in cells: moves the level's player start (WAD rebuild) | + +**Start bound to a level** (2026-11-07): a start with "@ world/chapter" +(`BootToLevelMod.formatStart(world, chapter, …)`) is used only while `world`/`chapter` name that +level; for any other level it is ignored with a progress note ("Player start ignored: it was set +for world W chapter C") instead of failing with "no collision floor" (a play-test whose restore +never ran left its start in config.json, and every later apply for another level failed). A start +without "@" applies to whatever level is chosen (old configs, hand-typed values) and is still +checked strictly. Tests: `BootToLevelModTest.startOfAnotherLevelIsIgnored`, `parsesStartLevel`. +The editor's play-test (`PlaytestRunner.install`) should write the bound form – see the report. Patches: `jmp` at 0x549C50 (main-menu frame handler) → once: read slots (FUN_0041c0e0), load slot (FUN_0041cc70), set tribe/world/chapter/map 0x584EB4…, music shutdown, state 0x5846E4 = 3, diff --git a/src/main/java/com/eliasmueller/llamamod/Main.java b/src/main/java/com/eliasmueller/llamamod/Main.java index 0776f4a..c028f44 100644 --- a/src/main/java/com/eliasmueller/llamamod/Main.java +++ b/src/main/java/com/eliasmueller/llamamod/Main.java @@ -184,8 +184,12 @@ public final class Main { requireInstalled(cfg); new SaveStore(paths).migrateNullSaves().forEach(System.out::println); ModManager mm = new ModManager(paths, cfg); - if (mm.recoverInterruptedApply()) { - System.out.println("Repaired the CD folder of an interrupted apply."); + switch (mm.recoverInterruptedApply()) { + case FINISHED -> System.out.println("The last apply was interrupted; LlamaMod finished it."); + case RESTORED -> System.out.println( + "The last apply was interrupted; LlamaMod restored the previous mod selection."); + case BUSY -> throw new ModManager.BusyException(); + default -> {} } checkExe(mm, paths); GameLauncher gl = new GameLauncher(paths, cfg); diff --git a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java index 1c8751d..cb14a37 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/ModManager.java @@ -364,6 +364,14 @@ public final class ModManager { } } + /** Another window, the CLI or the editor's play-test holds the apply lock. */ + public static final class BusyException extends IOException { + public BusyException() { + super("Another LlamaMod window or command is applying mods right now." + + " Please wait until it has finished and try again."); + } + } + /** * Exclusive lock on the data folder while cd-active, the cache and game/groove.exe are * rebuilt: a second LlamaMod window, the CLI or the editor's play-test would otherwise delete @@ -385,8 +393,7 @@ public final class ModManager { } if (l == null) { ch.close(); - throw new IOException("Another LlamaMod window or command is applying mods right now." - + " Please wait until it has finished and try again."); + throw new BusyException(); } return new DataLock(ch, l); } @@ -711,48 +718,75 @@ public final class ModManager { return paths.cdActive().resolveSibling("cd-active.new.complete"); } + /** Outcome of {@link #recoverInterruptedApply()}. */ + public enum Recovery { + /** Nothing was interrupted. */ + NONE, + /** The complete new folder was moved in: the interrupted apply is finished. */ + FINISHED, + /** The new folder was unusable: the previous mod selection is back. */ + RESTORED, + /** Another window or command holds the apply lock: try again later. */ + BUSY, + /** Neither folder is usable; the next apply or Play builds cd-active again. */ + FAILED; + + public boolean repaired() { + return this == FINISHED || this == RESTORED; + } + } + + /** + * Which leftover folder replaces a missing {@code cd-active}: {@code cd-active.new} only if + * it is complete ({@code built} = its marker, null if none) and built for the selection + * config.json records; otherwise {@code cd-active.old}, the folder of that selection. + */ + static Recovery chooseRecovery(String built, String recorded, boolean newExists, boolean oldExists) { + if (built != null && built.equals(recorded) && newExists) { + return Recovery.FINISHED; + } + return oldExists ? Recovery.RESTORED : Recovery.FAILED; + } + /** * Repairs a {@code cd-active} that is missing because an apply was killed between its two - * renames: {@code cd-active.new} is moved in if it is complete and built for the selection - * config.json records (activeKey, which matches game/groove.exe because both are written only - * after the swap); otherwise {@code cd-active.old}, the folder of that recorded selection, is - * moved back. Returns false if nothing was repaired (no interruption, another apply running). + * renames (see {@link #chooseRecovery}); config.json's activeKey matches game/groove.exe + * because both are written only after the swap. */ - public boolean recoverInterruptedApply() { + public Recovery recoverInterruptedApply() { Path active = paths.cdActive(); if (Files.exists(active)) { - return false; + return Recovery.NONE; } Path next = active.resolveSibling("cd-active.new"); Path old = active.resolveSibling("cd-active.old"); if (!Files.isDirectory(next) && !Files.isDirectory(old)) { - return false; + return Recovery.NONE; } // no ApplyLog: it would truncate apply.log, which tells what the interrupted apply did try (DataLock lock = DataLock.acquire(paths)) { if (Files.exists(active)) { - return false; + return Recovery.NONE; } Path marker = completeMarker(); String built = Files.isRegularFile(marker) ? Files.readString(marker, StandardCharsets.UTF_8) : null; String recorded = Config.load(paths.config()).activeKey; - Path use; - if (built != null && built.equals(recorded) && Files.isDirectory(next)) { - use = next; - } else if (Files.isDirectory(old)) { - use = old; - } else { + Recovery r = chooseRecovery(built, recorded, Files.isDirectory(next), Files.isDirectory(old)); + if (r == Recovery.FAILED) { System.err.println("interrupted apply: cd-active missing, cd-active.new " + (built == null ? "incomplete" : "built for another selection") + " – not recovered"); - return false; + return r; } + Path use = r == Recovery.FINISHED ? next : old; FileOps.move(use, active); Files.deleteIfExists(marker); FileOps.deleteTree(use == next ? old : next); System.err.println("interrupted apply: recovered cd-active from " + use.getFileName()); - return true; + return r; + } catch (BusyException e) { + return Recovery.BUSY; } catch (IOException e) { - return false; + return Recovery.FAILED; } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java index 7ee905e..45a87e0 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelMod.java @@ -100,7 +100,7 @@ public final class BootToLevelMod implements Mod { "start", "Player start", "Leave empty for the level's own start. Advanced: \"x, y, z[, angle]\" in cells moves the" - + " start.", + + " start; \"… @ world/chapter\" uses it only in that level.", "")); } @@ -118,7 +118,14 @@ public final class BootToLevelMod implements Mod { .noneMatch(p -> p.toLowerCase(Locale.ROOT).startsWith("wads/t1l9m"))) { throw new IOException("No TestLevel is installed (enable a level mod for world 9 first)"); } - double[] start = parseStart(ctx.option("start")); + String startText = ctx.option("start"); + double[] start = parseStart(startText); + int[] bound = startLevel(startText); + if (start != null && bound != null && (bound[0] != world || bound[1] != chapter)) { + // left over from a play-test of another level: that level's coordinates mean nothing here + ctx.progress(0, "Player start ignored: it was set for world " + bound[0] + " chapter " + bound[1]); + start = null; + } if (start != null) { if (!ctx.exists(wad)) { throw new IOException("Player start override: " + wad + " does not exist"); @@ -186,12 +193,20 @@ public final class BootToLevelMod implements Mod { return String.format(Locale.ROOT, "wads/T1L%dM%03d.wad", world, chapter); } - /** "x, y, z[, yaw]" → {x, y, z, yaw or NaN}; empty → null. */ + /** + * "x, y, z[, yaw][ @ world/chapter]" → {x, y, z, yaw or NaN}; empty → null. The level part + * (see {@link #startLevel}) is not part of the position. + */ static double[] parseStart(String s) throws IOException { if (s == null || s.isBlank()) { return null; } - String[] p = s.trim().split("\\s*[,; ]\\s*"); + int at = s.indexOf('@'); + String pos = at < 0 ? s : s.substring(0, at); + if (pos.isBlank()) { + throw new IOException("Player start must be \"x, y, z\" or \"x, y, z, yaw\": " + s); + } + String[] p = pos.trim().split("\\s*[,; ]\\s*"); if (p.length < 3 || p.length > 4) { throw new IOException("Player start must be \"x, y, z\" or \"x, y, z, yaw\": " + s); } @@ -206,6 +221,31 @@ public final class BootToLevelMod implements Mod { return v; } + /** + * The level a start is bound to ("… @ 3/2" = world 3, chapter 2), or null for a start without + * one (applies to whatever level is chosen). + */ + static int[] startLevel(String s) throws IOException { + int at = s == null ? -1 : s.indexOf('@'); + if (at < 0) { + return null; + } + String[] p = s.substring(at + 1).trim().split("\\s*/\\s*"); + try { + if (p.length == 2) { + return new int[] {Integer.parseInt(p[0]), Integer.parseInt(p[1])}; + } + } catch (NumberFormatException e) { + // fall through + } + throw new IOException("Player start: the level after @ must be \"world/chapter\": " + s); + } + + /** A start bound to one level: ignored (with a note) when another level is chosen. */ + public static String formatStart(int world, int chapter, double x, double y, double z, double yaw) { + return formatStart(x, y, z, yaw) + " @ " + world + "/" + chapter; + } + public static String formatStart(double x, double y, double z, double yaw) { return Double.isNaN(yaw) ? String.format(Locale.ROOT, "%.3f, %.3f, %.3f", x, y, z) diff --git a/src/main/java/com/eliasmueller/llamamod/platform/AppPaths.java b/src/main/java/com/eliasmueller/llamamod/platform/AppPaths.java index 8c51965..f758a71 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/AppPaths.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/AppPaths.java @@ -19,6 +19,14 @@ import java.util.prefs.Preferences; public record AppPaths(Path root) { private static final String PREF_KEY = "dataDir"; + /** + * Without "..": the installer compares normalized file paths with the root ("Set up again" + * failed with "Invalid path in source" for LLAMAMOD_DATA=/x/y/../data). + */ + public AppPaths { + root = root.normalize(); + } + public static Path defaultRoot() { if (Os.isWindows()) { String la = System.getenv("LOCALAPPDATA"); diff --git a/src/main/java/com/eliasmueller/llamamod/platform/Config.java b/src/main/java/com/eliasmueller/llamamod/platform/Config.java index f4e58a3..68fbaa2 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/Config.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/Config.java @@ -144,22 +144,52 @@ public final class Config { * Saves under a lock file and merges with what is on disk: fields this instance did not change * since it loaded or last saved the file keep the value another window or command wrote * meanwhile (mod options per mod and key). Afterwards this object holds the merged values. + * + * @return true if that pulled in values written elsewhere (views showing this config are stale) */ - public void save(Path file) throws IOException { + public boolean save(Path file) throws IOException { synchronized (Config.class) { // FileLock is per process; threads of this JVM wait here Path dir = file.toAbsolutePath().getParent(); Files.createDirectories(dir); try (FileChannel ch = FileChannel.open( dir.resolve(".config.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); FileLock lock = ch.lock()) { - Map merged = merge(base, snapshot(), readMap(file)); + Map mine = snapshot(); + Map merged = merge(base, mine, readMap(file)); read(merged); FileOps.writeAtomic(file, Json.write(merged).getBytes(StandardCharsets.UTF_8)); base = snapshot(); + return !base.equals(mine); } } } + /** + * Takes over what another window or command saved since this instance last loaded or saved + * the file, keeping this instance's unsaved changes (same merge as {@link #save}, nothing is + * written). + * + * @return true if any value changed + */ + public boolean refresh(Path file) { + synchronized (Config.class) { + Map disk = readMap(file); + if (base == null || disk == null) { + return false; + } + Config onDisk = new Config(); + onDisk.read(disk); + Map diskBase = onDisk.snapshot(); + if (diskBase.equals(base)) { + return false; + } + Map mine = snapshot(); + read(merge(base, mine, disk)); + base = diskBase; + return !snapshot().equals(mine); + } + } + private static Map readMap(Path file) { try { if (Files.isRegularFile(file) diff --git a/src/main/java/com/eliasmueller/llamamod/ui/App.java b/src/main/java/com/eliasmueller/llamamod/ui/App.java index 464148a..846e9fe 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/App.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/App.java @@ -49,7 +49,7 @@ public final class App { /** Explanation below or beside a control, wrapped at {@code width} (trusted HTML). */ static JLabel hintLabel(String html, int width, float fontSize) { - JLabel l = new JLabel(MainFrame.html(html, width)); + JLabel l = new WrapLabel(html, width); l.setFont(l.getFont().deriveFont(fontSize)); l.setForeground(hintColor()); return l; diff --git a/src/main/java/com/eliasmueller/llamamod/ui/AppState.java b/src/main/java/com/eliasmueller/llamamod/ui/AppState.java index 931ba0b..e42e955 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/AppState.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/AppState.java @@ -20,6 +20,8 @@ final class AppState { GameLauncher launcher; SaveStore saves; ModManager modManager; + /** Result of the interrupted-apply check at start (or after a data folder change), not yet shown. */ + ModManager.Recovery recovery = ModManager.Recovery.NONE; AppState(AppPaths paths) { setPaths(paths); @@ -31,9 +33,7 @@ final class AppState { launcher = new GameLauncher(p, cfg); saves = new SaveStore(p); modManager = new ModManager(p, cfg); - if (cfg.installed()) { - modManager.recoverInterruptedApply(); - } + recovery = cfg.installed() ? modManager.recoverInterruptedApply() : ModManager.Recovery.NONE; } void reloadMods() { @@ -48,7 +48,13 @@ final class AppState { .toList(); } - void saveConfig() throws IOException { - cfg.save(paths.config()); + /** True if the save pulled in changes of another window (see {@link Config#save}). */ + boolean saveConfig() throws IOException { + return cfg.save(paths.config()); + } + + /** Takes over config.json changes of other windows; true if any value changed. */ + boolean refreshConfig() { + return cfg.refresh(paths.config()); } } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/HeaderPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/HeaderPanel.java index 841f67e..9915415 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/HeaderPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/HeaderPanel.java @@ -3,8 +3,11 @@ package com.eliasmueller.llamamod.ui; import com.eliasmueller.llamamod.format.CorImage; import java.awt.AlphaComposite; import java.awt.Color; +import java.awt.Component; +import java.awt.Container; import java.awt.Dimension; import java.awt.Font; +import java.awt.FontMetrics; import java.awt.GradientPaint; import java.awt.Graphics; import java.awt.Graphics2D; @@ -12,6 +15,7 @@ import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.nio.file.Files; import java.nio.file.Path; +import javax.swing.JButton; import javax.swing.JPanel; import javax.swing.UIManager; @@ -94,8 +98,46 @@ final class HeaderPanel extends JPanel { if (!subtitle.isEmpty()) { g.setFont(base.deriveFont(Font.PLAIN, 12.5f)); g.setColor(new Color(255, 255, 255, 200)); - g.drawString(subtitle, x, y3); + g.drawString(elide(subtitle, g.getFontMetrics(), textRight() - x), x, y3); } g.dispose(); } + + /** Left edge of the buttons on the header, minus a gap (the subtitle must not run under them). */ + private int textRight() { + int right = getWidth(); + for (Component c : getComponents()) { + if (c instanceof Container box && box.getComponentCount() > 0 && !(c instanceof JButton)) { + for (Component b : box.getComponents()) { + if (b.isVisible() && b.getWidth() > 0) { + right = Math.min(right, c.getX() + b.getX()); + } + } + } else if (c.isVisible()) { + right = Math.min(right, c.getX()); + } + } + return right - 16; + } + + /** Cuts the middle out of {@code s} (keeping the end of a path) until it fits {@code width}. */ + static String elide(String s, FontMetrics fm, int width) { + if (fm.stringWidth(s) <= width) { + return s; + } + int keepStart = s.length() / 2; + int keepEnd = s.length() - keepStart; + while (keepStart > 0 || keepEnd > 0) { + if (keepStart >= keepEnd && keepStart > 0) { + keepStart--; + } else { + keepEnd--; + } + String t = s.substring(0, keepStart) + "…" + s.substring(s.length() - keepEnd); + if (fm.stringWidth(t) <= width) { + return t; + } + } + return "…"; + } } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java index f782a73..8ff2f66 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java @@ -60,6 +60,8 @@ final class MainFrame extends JFrame { private boolean gameRunning; private boolean crashReported; private boolean oldWineAccepted; + private final RecoveryNotice recoveryNotice = new RecoveryNotice(); + private boolean recoveryRunning; MainFrame(AppState state) { super("LlamaMod"); @@ -83,6 +85,12 @@ final class MainFrame extends JFrame { dispose(); System.exit(0); } + + @Override + public void windowActivated(WindowEvent e) { + pullConfig(); + retryRecovery(); + } }); setIconImages(icons()); buildUi(); @@ -96,7 +104,11 @@ final class MainFrame extends JFrame { }); setLocationRelativeTo(null); refreshAll(); - new Timer(3000, e -> updateGameRunning()).start(); + new Timer(3000, e -> { + updateGameRunning(); + retryRecovery(); + }) + .start(); } static List icons() { @@ -202,6 +214,77 @@ final class MainFrame extends JFrame { settingsPanel.reload(); updateGameRunning(); setStatus(inst ? Strings.t("status.ready") : Strings.t("status.notInstalled")); + if (state.recovery != ModManager.Recovery.NONE) { + showRecovery(state.recovery); + state.recovery = ModManager.Recovery.NONE; + } + } + + /** Tells the user once that an interrupted apply was repaired; BUSY is retried later. */ + private void showRecovery(ModManager.Recovery r) { + String key = recoveryNotice.handle(r); + if (key == null) { + return; + } + setStatus(Strings.t(key)); + SwingUtilities.invokeLater( + () -> JOptionPane.showMessageDialog(this, Strings.t(key), "LlamaMod", JOptionPane.INFORMATION_MESSAGE)); + } + + /** After another window held the apply lock: checks again once nothing runs here. */ + private void retryRecovery() { + if (!recoveryNotice.needsRetry() || recoveryRunning || busy()) { + return; + } + recoveryRunning = true; + var mm = state.modManager; + new Thread( + () -> { + ModManager.Recovery r = mm.recoverInterruptedApply(); + SwingUtilities.invokeLater(() -> { + recoveryRunning = false; + showRecovery(r); + }); + }, + "recover-apply") + .start(); + } + + /** + * Takes over config.json changes another window saved (mod selection, options, settings) so + * the next click here does not undo them. + */ + private void pullConfig() { + if (busy() || !state.cfg.installed()) { + return; + } + String theme = state.cfg.theme; + if (state.refreshConfig()) { + configChangedElsewhere(theme); + } + } + + /** Saves config.json; if that merged in values of another window, shows them. */ + void saveConfig() { + String theme = state.cfg.theme; + try { + if (state.saveConfig()) { + // later: the caller may be in the middle of an event of a component the reload replaces + SwingUtilities.invokeLater(() -> configChangedElsewhere(theme)); + } + } catch (Exception e) { + error(e); + } + } + + private void configChangedElsewhere(String themeBefore) { + if (!state.cfg.theme.equals(themeBefore)) { + reloadTheme(); + return; + } + modsPanel.reload(); + settingsPanel.reload(); + setStatus(Strings.t("status.configMerged")); } void onBusy(Consumer l) { @@ -498,7 +581,11 @@ final class MainFrame extends JFrame { Strings.t("task.launch"), p -> { state.saves.migrateNullSaves(); - state.modManager.recoverInterruptedApply(); + ModManager.Recovery rec = state.modManager.recoverInterruptedApply(); + SwingUtilities.invokeLater(() -> showRecovery(rec)); + if (rec == ModManager.Recovery.BUSY) { + throw new ModManager.BusyException(); + } if (!Files.isDirectory(state.paths.cdActive())) { state.modManager.restoreOriginal(p.sub(0, 0.3)); } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java index 3a5b6b7..7c29c26 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java @@ -18,7 +18,6 @@ import java.awt.GridBagLayout; import java.awt.GridLayout; import java.awt.Insets; import java.awt.Point; -import java.awt.Rectangle; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.IOException; @@ -44,7 +43,6 @@ import javax.swing.JScrollPane; import javax.swing.JSpinner; import javax.swing.JTextArea; import javax.swing.JTextField; -import javax.swing.Scrollable; import javax.swing.SpinnerNumberModel; import javax.swing.SwingUtilities; import javax.swing.UIManager; @@ -78,7 +76,7 @@ final class ModsPanel extends JPanel { add(intro, BorderLayout.NORTH); list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS)); - JPanel holder = new WidthTrackingPanel(); + JPanel holder = new WidthTrackingPanel(new BorderLayout()); holder.add(list, BorderLayout.NORTH); scroll = new JScrollPane(holder); scroll.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); @@ -105,38 +103,6 @@ final class ModsPanel extends JPanel { }); } - /** Lays the cards out at the viewport width (text wraps instead of scrolling sideways). */ - private static final class WidthTrackingPanel extends JPanel implements Scrollable { - WidthTrackingPanel() { - super(new BorderLayout()); - } - - @Override - public Dimension getPreferredScrollableViewportSize() { - return getPreferredSize(); - } - - @Override - public int getScrollableUnitIncrement(Rectangle r, int orientation, int direction) { - return 16; - } - - @Override - public int getScrollableBlockIncrement(Rectangle r, int orientation, int direction) { - return r.height - 32; - } - - @Override - public boolean getScrollableTracksViewportWidth() { - return true; - } - - @Override - public boolean getScrollableTracksViewportHeight() { - return false; - } - } - private void updateEnabled() { boolean on = !locked && frame.state.cfg.installed(); applyButton.setEnabled(on); @@ -642,11 +608,7 @@ final class ModsPanel extends JPanel { } private void saveConfig() { - try { - frame.state.saveConfig(); - } catch (Exception ex) { - frame.error(ex); - } + frame.saveConfig(); } // ---------------------------------------------------------------- state diff --git a/src/main/java/com/eliasmueller/llamamod/ui/RecoveryNotice.java b/src/main/java/com/eliasmueller/llamamod/ui/RecoveryNotice.java new file mode 100644 index 0000000..c65ac18 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/ui/RecoveryNotice.java @@ -0,0 +1,23 @@ +package com.eliasmueller.llamamod.ui; + +import com.eliasmueller.llamamod.mods.ModManager.Recovery; + +/** What the window does with the result of an interrupted-apply check. */ +final class RecoveryNotice { + private boolean retry; + + /** Message key to show the user once, or null; remembers whether to try again later. */ + String handle(Recovery r) { + retry = r == Recovery.BUSY; + return switch (r) { + case FINISHED -> "recover.finished"; + case RESTORED -> "recover.restored"; + default -> null; + }; + } + + /** Another window held the apply lock at the last check. */ + boolean needsRetry() { + return retry; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java index 6681c1e..ba3faf6 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java @@ -146,7 +146,7 @@ final class SavesPanel extends JPanel { g.gridy++; g.weighty = 0; g.fill = GridBagConstraints.HORIZONTAL; - ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 560, 11.5f), g); + ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g); add(ed, BorderLayout.CENTER); JPanel buttons = new JPanel(new BorderLayout()); diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java index 1efecff..6825cef 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SettingsPanel.java @@ -51,16 +51,16 @@ final class SettingsPanel extends JPanel { private final JTextField wine = new JTextField(24); private final JComboBox theme = new JComboBox<>(new String[] {Strings.t("settings.theme.light"), Strings.t("settings.theme.dark")}); - private final JLabel dataDir = new JLabel(); + private final JLabel dataDir = new WrapLabel("", 2000); private final JLabel cacheInfo = new JLabel(); - private final JLabel wineInfo = new JLabel(); + private final JLabel wineInfo = new WrapLabel("", 640); private boolean loading; SettingsPanel(MainFrame frame) { super(new BorderLayout()); this.frame = frame; setBorder(BorderFactory.createEmptyBorder(16, 20, 12, 20)); - JPanel p = new JPanel(new GridBagLayout()); + JPanel p = new WidthTrackingPanel(new GridBagLayout()); int y = 0; resolution.addItem(Strings.t("settings.auto")); @@ -178,14 +178,19 @@ final class SettingsPanel extends JPanel { g.gridx = 1; g.weightx = 1; g.insets = new Insets(4, 0, 4, 0); - JPanel w = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); - w.add(c); - p.add(w, g); + g.fill = GridBagConstraints.HORIZONTAL; + if (c instanceof WrapLabel) { + p.add(c, g); + } else { + JPanel w = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + w.add(c); + p.add(w, g); + } y++; if (hint != null) { g.gridy = y; g.insets = new Insets(0, 0, 4, 0); - p.add(App.hintLabel(hint, 520, 11.5f), g); + p.add(App.hintLabel(hint, 620, 11.5f), g); y++; } return y; @@ -206,7 +211,7 @@ final class SettingsPanel extends JPanel { virtualDesktop.setSelected(cfg.virtualDesktop); wine.setText(cfg.wineCommand); theme.setSelectedIndex("dark".equals(cfg.theme) ? 1 : 0); - dataDir.setText(frame.state.paths.root().toString()); + dataDir.setText(breakablePath(frame.state.paths.root().toString())); GameLauncher.Resolution auto = GameLauncher.autoResolution(!Os.isWindows() && cfg.virtualDesktop); resolution.setToolTipText(Strings.t("settings.autoIs", auto.w() + "x" + auto.h())); // show what "Automatic" means right in the list @@ -222,7 +227,7 @@ final class SettingsPanel extends JPanel { String version = ok ? frame.state.launcher.wineVersion() : null; boolean tooOld = GameLauncher.wineTooOld(GameLauncher.parseWineVersion(version)); String min = GameLauncher.MIN_WINE[0] + "." + GameLauncher.MIN_WINE[1]; - wineInfo.setText( + wineInfo.setText(MainFrame.escape( w == null ? Strings.t("settings.wineMissing") : !ok @@ -231,13 +236,18 @@ final class SettingsPanel extends JPanel { ? Strings.t("settings.wineTooOld", w, version, min) : version != null ? Strings.t("settings.wineFoundVersion", w, version) - : Strings.t("settings.wineFound", w)); + : Strings.t("settings.wineFound", w))); wineInfo.setForeground(ok && !tooOld ? UIManager.getColor("Label.foreground") : App.ERROR); } updateCache(); loading = false; } + /** HTML for a long path that may wrap after its separators. */ + static String breakablePath(String path) { + return MainFrame.escape(path).replaceAll("([/\\\\])", "$1\u200B"); + } + private void updateCache() { try { long b = frame.state.modManager.cacheSize(); @@ -271,11 +281,7 @@ final class SettingsPanel extends JPanel { cfg.virtualDesktop = virtualDesktop.isSelected(); cfg.wineCommand = wine.getText().trim(); cfg.theme = theme.getSelectedIndex() == 1 ? "dark" : "light"; - try { - frame.state.saveConfig(); - } catch (Exception e) { - frame.error(e); - } + frame.saveConfig(); } private void clearCache() { diff --git a/src/main/java/com/eliasmueller/llamamod/ui/WidthTrackingPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/WidthTrackingPanel.java new file mode 100644 index 0000000..bafc0fa --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/ui/WidthTrackingPanel.java @@ -0,0 +1,39 @@ +package com.eliasmueller.llamamod.ui; + +import java.awt.Dimension; +import java.awt.LayoutManager; +import java.awt.Rectangle; +import javax.swing.JPanel; +import javax.swing.Scrollable; + +/** Scroll pane content laid out at the viewport width: text wraps instead of scrolling sideways. */ +final class WidthTrackingPanel extends JPanel implements Scrollable { + WidthTrackingPanel(LayoutManager layout) { + super(layout); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle r, int orientation, int direction) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(Rectangle r, int orientation, int direction) { + return r.height - 32; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return false; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/ui/WrapLabel.java b/src/main/java/com/eliasmueller/llamamod/ui/WrapLabel.java new file mode 100644 index 0000000..8defb0b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/ui/WrapLabel.java @@ -0,0 +1,84 @@ +package com.eliasmueller.llamamod.ui; + +import java.awt.Dimension; +import java.awt.Insets; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.SwingUtilities; +import javax.swing.border.Border; +import javax.swing.plaf.basic.BasicHTML; +import javax.swing.text.View; + +/** + * Label with trusted HTML that wraps at the width its container gives it (lines at most + * {@code maxWidth} long) and asks for almost no width itself, so it never forces a wider window + * or a horizontal scroll bar. Give it a horizontal fill in its layout. + */ +final class WrapLabel extends JLabel { + private final int maxWidth; + private Border base; + private int laidOutWidth = -1; + + WrapLabel(String html, int maxWidth) { + this.maxWidth = maxWidth; + setText(html); + setVerticalAlignment(TOP); + } + + @Override + public void setText(String html) { + super.setText(html == null || html.isEmpty() ? html : "" + html + ""); + } + + @Override + public void setBorder(Border b) { + base = b; + super.setBorder(b); + } + + private Insets baseInsets() { + return base == null ? new Insets(0, 0, 0, 0) : base.getBorderInsets(this); + } + + /** Text width: the current width, at most maxWidth, before the first layout maxWidth. */ + private int textWidth() { + Insets in = baseInsets(); + int w = getWidth() > 0 ? getWidth() - in.left - in.right : maxWidth; + return Math.max(20, Math.min(w, maxWidth)); + } + + @Override + public Dimension getPreferredSize() { + View v = (View) getClientProperty(BasicHTML.propertyKey); + if (v == null || isPreferredSizeSet()) { + return super.getPreferredSize(); + } + Insets in = baseInsets(); + int tw = textWidth(); + v.setSize(tw, 0); + int natural = (int) Math.ceil(v.getPreferredSpan(View.X_AXIS)); + int h = (int) Math.ceil(v.getPreferredSpan(View.Y_AXIS)); + return new Dimension(Math.min(natural, 60) + in.left + in.right, h + in.top + in.bottom); + } + + @Override + public Dimension getMinimumSize() { + return getPreferredSize(); + } + + @Override + public void setBounds(int x, int y, int w, int h) { + super.setBounds(x, y, w, h); + if (w != laidOutWidth) { + laidOutWidth = w; + // lines end at maxWidth even in a wider cell + Insets in = baseInsets(); + int extra = Math.max(0, w - in.left - in.right - maxWidth); + Border pad = BorderFactory.createEmptyBorder(0, 0, 0, extra); + super.setBorder(base == null ? pad : BorderFactory.createCompoundBorder(base, pad)); + if (getPreferredSize().height != h) { + SwingUtilities.invokeLater(this::revalidate); + } + } + } +} diff --git a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties index 04f0d00..7485503 100644 --- a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties +++ b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties @@ -30,6 +30,9 @@ status.failed={0} – failed. status.cancelled=Cancelled. status.gameRunning=The game is running. status.gameEnded=The game has ended. +status.configMerged=Took over changes made in another LlamaMod window. +recover.finished=The last apply was interrupted; LlamaMod finished it. +recover.restored=The last apply was interrupted; LlamaMod restored the previous mod selection. task.apply=Applying mods task.restore=Restoring original diff --git a/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java b/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java index b58c1bc..6cec304 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/InterruptedApplyTest.java @@ -8,8 +8,11 @@ import com.eliasmueller.llamamod.platform.AppPaths; import com.eliasmueller.llamamod.platform.Config; import com.eliasmueller.llamamod.platform.Progress; import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -78,12 +81,12 @@ class InterruptedApplyTest { Files.move(paths.cdActive(), next); Files.writeString(marker, key); folder(old, 9); - assertTrue(mm.recoverInterruptedApply()); + assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply()); assertEquals(2, wad()); assertFalse(Files.exists(next)); assertFalse(Files.exists(old)); assertFalse(Files.exists(marker)); - assertFalse(mm.recoverInterruptedApply(), "nothing left to repair"); + assertEquals(ModManager.Recovery.NONE, mm.recoverInterruptedApply(), "nothing left to repair"); } @Test @@ -94,7 +97,7 @@ class InterruptedApplyTest { Files.move(paths.cdActive(), old); folder(next, 2); Files.writeString(marker, mm.selectionKey(List.of(new Two()))); - assertTrue(mm.recoverInterruptedApply()); + assertEquals(ModManager.Recovery.RESTORED, mm.recoverInterruptedApply()); assertEquals(1, wad()); assertFalse(Files.exists(next)); assertFalse(Files.exists(marker)); @@ -105,7 +108,7 @@ class InterruptedApplyTest { ModManager mm = install(); folder(next, 1); Files.writeString(marker, ""); // restoring the original; config.json activeKey is "" too - assertTrue(mm.recoverInterruptedApply()); + assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply()); assertEquals(1, wad()); } @@ -113,11 +116,40 @@ class InterruptedApplyTest { void incompleteNewFolderIsNotUsed() throws Exception { ModManager mm = install(); folder(next, 2); // killed while building: no marker - assertFalse(mm.recoverInterruptedApply()); + assertEquals(ModManager.Recovery.FAILED, mm.recoverInterruptedApply()); assertFalse(Files.exists(paths.cdActive())); // the next apply builds everything again mm.apply(List.of(new Two()), Progress.NONE); assertEquals(2, wad()); assertFalse(Files.exists(next)); } + + @Test + void lockedByAnotherWindowIsReportedAsBusyAndRetriedLater() throws Exception { + ModManager mm = install(); + folder(next, 1); + Files.writeString(marker, ""); + try (FileChannel ch = FileChannel.open( + tmp.resolve(".apply.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock l = ch.lock()) { + assertEquals(ModManager.Recovery.BUSY, mm.recoverInterruptedApply()); + assertFalse(Files.exists(paths.cdActive()), "nothing touched while locked"); + } + assertEquals(ModManager.Recovery.FINISHED, mm.recoverInterruptedApply()); + assertEquals(1, wad()); + } + + @Test + void chooseRecovery() { + assertEquals(ModManager.Recovery.FINISHED, ModManager.chooseRecovery("k", "k", true, true)); + assertEquals(ModManager.Recovery.FINISHED, ModManager.chooseRecovery("k", "k", true, false)); + assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery("other", "k", true, true)); + assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery(null, "k", true, true)); + assertEquals(ModManager.Recovery.RESTORED, ModManager.chooseRecovery("k", "k", false, true)); + assertEquals(ModManager.Recovery.FAILED, ModManager.chooseRecovery(null, "k", true, false)); + assertEquals(ModManager.Recovery.FAILED, ModManager.chooseRecovery("other", "k", true, false)); + assertTrue(ModManager.Recovery.FINISHED.repaired()); + assertTrue(ModManager.Recovery.RESTORED.repaired()); + assertFalse(ModManager.Recovery.BUSY.repaired()); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java index 4ac0adc..0059460 100644 --- a/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java +++ b/src/test/java/com/eliasmueller/llamamod/mods/boottolevel/BootToLevelModTest.java @@ -34,9 +34,14 @@ class BootToLevelModTest { /** TestModContext turns empty values into "1"; the start option must stay empty. */ static ModContext ctx(BootToLevelMod m, Map o, ExeImage exe) throws IOException { + return ctx(m, o, exe, new java.util.ArrayList<>()); + } + + /** {@code log} receives "write " and the progress messages. */ + static ModContext ctx(BootToLevelMod m, Map o, ExeImage exe, List log) throws IOException { TestModContext t = new TestModContext(GameData.cd(), m, o, exe); Map v = new java.util.LinkedHashMap<>(t.optionValues()); - v.put("start", ""); + v.put("start", o.getOrDefault("start", "")); return new ModContext() { public Path originalCd() { return t.originalCd(); @@ -55,10 +60,13 @@ class BootToLevelModTest { } public void write(String p, byte[] d) throws IOException { + log.add("write " + p); t.write(p, d); } - public void progress(double f, String msg) {} + public void progress(double f, String msg) { + log.add(msg); + } public ExeImage exe() throws IOException { return t.exe(); @@ -92,6 +100,34 @@ class BootToLevelModTest { assertEquals(0x68, exe.readU8(IntroSkipMod.LOGOS)); } + @Test + void startOfAnotherLevelIsIgnored() throws Exception { + String stale = BootToLevelMod.formatStart(3, 1, 0, -999, 0, Double.NaN); // no floor anywhere here + assertEquals("0.000, -999.000, 0.000 @ 3/1", stale); + BootToLevelMod m = new BootToLevelMod(); + ExeImage exe = GameData.exe(); + exe.setOwner(BootToLevelMod.ID); + List log = new java.util.ArrayList<>(); + m.apply(ctx(m, Map.of("world", "1", "chapter", "2", "start", stale), exe, log)); + // used to fail: "no collision floor at or below it" + assertEquals(List.of("Player start ignored: it was set for world 3 chapter 1"), log); + // in its own level it is still used (and checked) + ExeImage exe2 = GameData.exe(); + exe2.setOwner(BootToLevelMod.ID); + IOException e = assertThrows( + IOException.class, () -> m.apply(ctx(m, Map.of("world", "3", "chapter", "1", "start", stale), exe2))); + assertTrue(e.getMessage().startsWith("Player start"), e.getMessage()); + } + + @Test + void parsesStartLevel() throws IOException { + assertNull(BootToLevelMod.startLevel("1, 2, 3")); + assertArrayEquals(new int[] {9, 12}, BootToLevelMod.startLevel("1, 2, 3, 90 @ 9/12")); + assertArrayEquals(new double[] {1, 2, 3, 90}, BootToLevelMod.parseStart("1, 2, 3, 90 @ 9/12")); + assertThrows(IOException.class, () -> BootToLevelMod.startLevel("1, 2, 3 @ 9")); + assertThrows(IOException.class, () -> BootToLevelMod.parseStart("@ 9/1")); + } + @Test void parsesStart() throws IOException { assertNull(BootToLevelMod.parseStart(" ")); diff --git a/src/test/java/com/eliasmueller/llamamod/platform/AppPathsTest.java b/src/test/java/com/eliasmueller/llamamod/platform/AppPathsTest.java new file mode 100644 index 0000000..2b7bf30 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/platform/AppPathsTest.java @@ -0,0 +1,15 @@ +package com.eliasmueller.llamamod.platform; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class AppPathsTest { + @Test + void rootIsNormalized() { + AppPaths p = new AppPaths(Path.of("/data/tmp/agent/../game-data")); + assertEquals(Path.of("/data/tmp/game-data"), p.root()); + assertEquals(Path.of("/data/tmp/game-data/original/hd"), p.originalHd()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java b/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java index feae2b3..a7dec70 100644 --- a/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java +++ b/src/test/java/com/eliasmueller/llamamod/platform/ConfigTest.java @@ -127,4 +127,57 @@ class ConfigTest { assertEquals(49, r.screenW); assertEquals(49, r.screenH); } + + @Test + void saveReportsWhetherItPulledInChangesOfAnotherWindow() throws Exception { + AppPaths p = new AppPaths(tmp); + Config c = new Config(); + c.layout = Config.LAYOUT; + c.save(p.config()); + Config a = Config.load(p.config()); + Config b = Config.load(p.config()); + a.enabledMods = new java.util.ArrayList<>(java.util.List.of("fog")); + assertFalse(a.save(p.config()), "nothing else changed"); + b.screenW = 800; + assertTrue(b.save(p.config()), "b took over a's mod selection"); + assertEquals(java.util.List.of("fog"), b.enabledMods); + b.screenH = 600; + assertFalse(b.save(p.config())); + } + + @Test + void refreshTakesOverOtherWindowsChangesAndKeepsUnsavedOnes() throws Exception { + AppPaths p = new AppPaths(tmp); + Config c = new Config(); + c.layout = Config.LAYOUT; + c.modOptions.put("fog", new java.util.TreeMap<>(java.util.Map.of("strength", "2"))); + c.save(p.config()); + Config window = Config.load(p.config()); + assertFalse(window.refresh(p.config()), "file unchanged"); + + Config other = Config.load(p.config()); + other.enabledMods = new java.util.ArrayList<>(java.util.List.of("fog")); + other.modOptions.get("fog").put("strength", "5"); + other.save(p.config()); + window.theme = "dark"; // unsaved change of this window + + assertTrue(window.refresh(p.config())); + assertEquals(java.util.List.of("fog"), window.enabledMods); + assertEquals("5", window.modOptions.get("fog").get("strength")); + assertEquals("dark", window.theme); + assertFalse(window.refresh(p.config()), "already up to date"); + + // the unsaved change still counts as this window's change at the next save + assertFalse(window.save(p.config())); + assertEquals("dark", Config.load(p.config()).theme); + assertEquals("5", Config.load(p.config()).modOptions.get("fog").get("strength")); + } + + @Test + void refreshIgnoresUnknownFieldsOfANewerVersion() throws Exception { + AppPaths p = new AppPaths(tmp); + Files.writeString(p.config(), "{\"layout\": 1, \"futureField\": 7}"); + Config c = Config.load(p.config()); + assertFalse(c.refresh(p.config())); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/ui/RecoveryNoticeTest.java b/src/test/java/com/eliasmueller/llamamod/ui/RecoveryNoticeTest.java new file mode 100644 index 0000000..b1cac7f --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/ui/RecoveryNoticeTest.java @@ -0,0 +1,58 @@ +package com.eliasmueller.llamamod.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.mods.ModManager.Recovery; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +class RecoveryNoticeTest { + @Test + void repairsAreShownAndBusyIsRetriedUntilTheLockIsFree() { + RecoveryNotice n = new RecoveryNotice(); + assertNull(n.handle(Recovery.NONE)); + assertFalse(n.needsRetry()); + assertNull(n.handle(Recovery.BUSY)); + assertTrue(n.needsRetry()); + assertEquals("recover.finished", n.handle(Recovery.FINISHED)); + assertFalse(n.needsRetry()); + n.handle(Recovery.BUSY); + assertNull(n.handle(Recovery.NONE), "the other window rebuilt cd-active itself"); + assertFalse(n.needsRetry()); + assertEquals("recover.restored", n.handle(Recovery.RESTORED)); + assertNull(n.handle(Recovery.FAILED)); + assertFalse(n.needsRetry()); + for (Recovery r : Recovery.values()) { + String key = n.handle(r); + assertEquals(r.repaired(), key != null); + if (key != null) { + assertFalse(Strings.t(key).startsWith("!"), key); + } + } + } + + @Test + void headerSubtitleIsCutInTheMiddle() { + FontMetrics fm = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) + .getGraphics() + .getFontMetrics(new Font(Font.SANS_SERIF, Font.PLAIN, 12)); + String s = "Version 0.2.0 · /home/someone/a/very/long/path/to/the/llamamod/data"; + assertEquals(s, HeaderPanel.elide(s, fm, 2000)); + String cut = HeaderPanel.elide(s, fm, 200); + assertTrue(fm.stringWidth(cut) <= 200); + assertTrue(cut.contains("…")); + assertTrue(cut.startsWith("Ver") && cut.endsWith("data"), cut); + assertEquals("…", HeaderPanel.elide(s, fm, 1)); + } + + @Test + void longPathsMayWrapAfterSeparators() { + assertEquals("/\u200Ba/\u200Bb<c", SettingsPanel.breakablePath("/a/b