play-test copies are temporary (levels/.playtest, removed and mod selection restored when the game ends), add to llamamod and keep option in the editor, remove level and open levels folder in the launcher plus --remove-level, unique level mod ids for files differing only in case, esc leaves the waypoint tool

This commit is contained in:
MineTec committed 2026-11-07 09:42:24 +01:00
1 parent 2386443fac
commit 1c8b766454
24 files changed
+823 -87

No files matched your search

+2
View File
@@ -83,6 +83,8 @@ that Ctrl+click therefore also inverts snapping).
* **Backspace** (while the tool is active) or **Delete** / **Edit → Delete** removes the selected
nodes and **relinks** the chain: the previous node now leads to the following one. The next
node becomes selected, so you can delete several in a row.
* **Esc** clears the waypoint selection; pressing it again (or **Q**) leaves the Waypoint tool
and returns to Select.
* Moving the owner object with the Place tool moves the whole chain along (option
*Move waypoints along*).
+18 -5
View File
@@ -47,15 +47,21 @@ status bar keeps the result of the last build; click it to open the Problems pan
**Level → Play** (**F5**, toolbar **Play**) does everything in one go:
1. builds the level,
2. installs it in LlamaMod's `levels` folder as a level mod (next to the mods you have switched
on),
2. installs a **temporary copy** of it as a level mod (next to the mods you have switched on),
3. prepares the play-test save slot (unlocked up to your level),
4. starts the game **directly in the level** – no logos, no menus, no map.
![The game started straight in the level](img/play_boot.jpg)
In the game, press **Escape** to quit straight back to the editor. When the game has ended,
LlamaMod switches your normal mod selection back on; the level itself stays installed.
LlamaMod switches your normal mod selection back on and deletes the temporary copy – play-tests
do not fill LlamaMod's mod list. While the game runs, the copy shows up in LlamaMod as
"*name* (play-test)" with a blue **temporary play-test** badge.
If the editor is closed before the game, the clean-up happens the next time the editor starts.
To keep a level in LlamaMod, use **Level → Add to LlamaMod…** (see
[Playing without the editor](#playing-without-the-editor)) or switch on **Keep play-tested level
in LlamaMod**.
### Play from the camera position
@@ -75,6 +81,7 @@ dropping the llama into the void. The original level's opening fly-by is skipped
|---|---|---|
| **Play boots straight into the level** | on | Off: the game starts at the main menu with the map cursor on your level (choose *Spiel starten* → slot → Enter on the map); groove.exe is not changed. |
| **Escape quits the play-test** | on | In a play-test the pause key ends the game at once. Off: Escape opens the normal pause menu. |
| **Keep play-tested level in LlamaMod** | off | On: Play installs the level as a normal level mod in the `levels` folder (as **Add to LlamaMod** does) and it stays switched on after the game. |
| Play-test save slot (**Edit → Preferences**) | 3 | The slot the game plays with; it is backed up before it is changed. |
> **Note:** Booting into the level uses the **Boot to level** mod, which adds code to LlamaMod's
@@ -95,6 +102,12 @@ More in [Troubleshooting and FAQ](20-troubleshooting.md).
## Playing without the editor
The level is now a mod in LlamaMod's **Levels** category. To play it the normal way, tick it in
LlamaMod, click **Apply** and **Play**. New levels are on the world-9 (TestLevel) map – unlock
**Level → Add to LlamaMod…** copies the current state of the level into LlamaMod's `levels`
folder as a normal level mod – on the TestLevel chapter chosen in the
[Level settings](14-level-settings.md), or replacing its original chapter. Adding the same
project again updates that mod. To play it the normal way, tick it in LlamaMod's **Levels**
category, click **Apply** and **Play**. New levels are on the world-9 (TestLevel) map – unlock
it in **Save games → Unlock TestLevel (world 9)**; edited original levels replace their chapter.
A level you no longer want in LlamaMod is removed with **Remove…** on its card
(see [Mods](18-mods.md#level-mods)).
+4 -3
View File
@@ -23,11 +23,12 @@ Any of these works:
* **Copy the file into the `levels` folder** of LlamaMod's data folder (**Settings → Data folder
→ Open folder**, then `levels`) and restart LlamaMod. The level appears in the **Levels**
category of the **Mods** tab.
* **Open it in the editor** (**File → Open project**) and press **F5**: the play-test installs it
in the `levels` folder and starts it.
* **Open it in the editor** (**File → Open project**) and choose **Level → Add to LlamaMod…**.
(**F5** only play-tests it: the copy is removed again when the game ends.)
* From the command line: `LlamaMod --import-level <file.llamalevel>`.
Then tick the level in LlamaMod, click **Apply** and **▶ Play**.
Then tick the level in LlamaMod, click **Apply** and **▶ Play**. To get rid of it again, click
**Remove…** on its card – the file is moved to the backups, see [Mods](18-mods.md#level-mods).
## Where the level appears in the game
+17
View File
@@ -39,6 +39,22 @@ option:
Edited original levels have no options – they replace their chapter. See
[Sharing levels](17-sharing.md).
**Removing a level:** click **Remove…** on its card. After you confirm, the project file is moved
to `backups/levels/<date-time>/` in the data folder – nothing is deleted, and you can copy it back
into `levels` to get the level again. This is refused while the game runs. If the level was
active, it is taken out of the selection and LlamaMod asks you to click **Apply**. Careful: the
editor saves projects in the `levels` folder by default, so the file may also be your editor
project – it is then in the backup folder.
**Open levels folder** (in the **Levels** header) shows the folder in your file manager.
Levels you play-test in the editor are only installed for the play-test and disappear when the
game ends (while it runs they show a **temporary play-test** badge). Older LlamaMod versions
kept every play-tested level; those files are ordinary level mods now – remove the ones you do
not want with **Remove…**.
![Removing a level](img/levels_remove.jpg)
## Command line
LlamaMod can be scripted:
@@ -48,6 +64,7 @@ LlamaMod --mods list all mods with their optio
LlamaMod --apply fog,mirror --opt fog.distance=400 apply mods with options
LlamaMod --apply restore the original game
LlamaMod --launch start the game
LlamaMod --remove-level level-my-level move a level mod's file to backups/levels/
```
Option values are checked and saved like in the window.
+3 -1
View File
@@ -219,10 +219,12 @@ Every menu command with its shortcut and a short description.
| Level settings… | | Name, chapter, grid size, death plane, feature flags |
| Check for problems | | List problems that would break the level (missing start, exits …) |
| Build | **Ctrl+B** | Validate the level and build its WAD (in the background) |
| Play | **F5** | Build the level, install it as a mod and start the game directly in it (no menus; your mod selection is restored when the game exits) |
| Play | **F5** | Build the level, install a temporary copy and start the game directly in it (no menus; when the game exits, the copy is removed and your mod selection restored) |
| Play from camera position | **Shift+F5** | Play-test with the llama standing on the ground point in the middle of the view, facing the camera direction |
| Play boots straight into the level (on/off) | | Off: Play starts at the main menu with the map cursor on the level (no change to groove.exe) |
| Escape quits the play-test (on/off) | | In a play-test the pause key (Escape) ends the game at once, back to the editor |
| Keep play-tested level in LlamaMod (on/off) | | Off: Play installs a temporary copy that is removed when the game exits. On: the level stays in LlamaMod's mod list (Mods → Levels) after testing |
| Add to LlamaMod… | | Copy the level into LlamaMod's level folder as a normal level mod (on its TestLevel chapter or original slot), to play it from LlamaMod or share the mod list |
| Bake lighting… | | |
| Environment zones | | |
| Visibility regions | | |
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+26 -7
View File
@@ -15,17 +15,26 @@ seconds after the game window opens, and **Escape** quits back to the editor.
| Play boots straight into the level | Level menu (check) | off = old behaviour (main menu, map cursor) |
| Escape quits the play-test | Level menu (check) | boot mod option `quitOnEscape` |
* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to
`<data>/levels/<stem>.llamalevel` → the user's **active** mods (others on the same WAD and Test
* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to the
**temporary** `<data>/levels/.playtest/<stem>.llamalevel` (mod id `playtest-<stem>`; with
"Keep play-tested level in LlamaMod" on: `<data>/levels/<stem>.llamalevel` as before) → the user's **active** mods (others on the same WAD and Test
World on chapter 1 turned off) + the level mod become `enabledMods`; `boot-to-level` is applied
**in addition** (not stored in `enabledMods`) with world/chapter of the project, slot
(Preferences, default 3), `logos=true`, `quitOnEscape`, `start` → save slot unlocked up to the
level → `GameLauncher.launch`.
* **Restore**: a daemon thread polls `GameLauncher.isRunning()` every 2 s; when the game has
ended it re-applies the user's selection without the boot mod (cached outputs, < 1 s) and
restores the boot mod's own options → LlamaMod's normal Play starts at the menu again
(`RestoreCheck`: active `[level-…, boot-to-level]` → `[level-…]`, exe without mod patches).
If the editor is closed before the game, LlamaMod shows the selection as "apply needed".
ended it re-applies the user's **previous** active mods and enabled list (stored in the
session: `mods`, `enabled`, `bootOptions`), restores the boot mod's own options and deletes
`levels/.playtest/` plus `playtest-*` entries in `enabledMods`/`modOptions`
(`LevelProjects.clearPlaytest`). With "keep" the level stays enabled/active (old behaviour).
The session file `<data>/editor/playtest-session.json` lets the next editor start finish it;
without a session, the next editor start deletes leftover copies if no game runs
(`clearStale`). The non-boot Play (menu, map cursor) uses the same install/restore path now.
* **Level → Add to LlamaMod…**: writes the snapshot to `<data>/levels/` (`PlayTest.installTarget`:
the same project's file is reused, file names compared case-insensitively) and sets the mod's
`chapter` option to the project's TestLevel chapter.
* The launcher hides `playtest-*` mods unless enabled/active (then: "(play-test)" + blue badge
"temporary play-test", no Remove button).
* **Play from camera position**: ground point = depth-buffer hit at the viewport centre
(export space → game x, y, −z), heading = camera yaw (`atan2(f.x, −f.z)`, 0 = +z).
`FloorFinder` then looks for **collision**: highest floor polygon (|n.y| ≥ 0.5) at or below
@@ -102,7 +111,17 @@ hardware is untested.
## Tests
`BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only
`PlaytestLifecycleTest` (temporary copy listed as `playtest-…` → restore removes it and puts
the enabled list back; keep → stays; stale copies cleared; Remove → backups, refused while the
game runs; regression: `My-level`/`my-level` get distinct ids), `BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only
when asked; start parsing; original village: only the llama moves, file size unchanged;
editor-style chain moves and turns with the llama), `RestoreCheck` (manual), `EdTour` +
`PlayTourSteps` (scripted editor runs on an X display: camera placement, play-from-here).
## Temporary play-test copies – verified in game (apptest lvlrm, game on :5, editor on :31, 2026-11-07)
EdTour `run:level.play` on `/tmp/…/mytest.llamalevel` (real launch, private pulse): during the
game `levels/.playtest/mytest.llamalevel`, enabled `[playtest-mytest]`, active
`[playtest-mytest, boot-to-level]`, Testwelt plaza in game (`img/mods/levels_playtest_temporary.jpg`);
Escape → game gone → `.playtest` deleted, enabled `[]` and active `[level-old-village-test]` as
before the test, session file removed. Launcher badge: `img/mods/levels_playtest_badge.jpg`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+3
View File
@@ -39,6 +39,7 @@ Runtime modules: java.base, java.desktop, java.prefs, java.logging, jdk.charsets
llamamod --version --install <iso|folder> --mods --launch [--wait]
llamamod --apply [id,id] [--opt id.key=value ...]
llamamod --registry --saves --unlock <slot 1-3> <world> <chapter> [--cursor]
llamamod --import-level <file> --remove-level <id|file>
```
`--mods` lists every mod with category and its options (current value, type, range, default).
`--opt` values are validated and saved in config.json like the GUI does. Errors (e.g. "The game is
@@ -60,6 +61,8 @@ different folder chosen in the wizard is remembered via `java.util.prefs`.
| `cd-active/` | what the game sees as its CD: hard links to originals + mod outputs |
| `cache/<mod>/<key>/` | mod outputs + `manifest.json` |
| `backups/saves/<timestamp>/` | save game backups (taken before every write) |
| `levels/` | level projects = level mods (`.playtest/`: temporary editor play-test copies) |
| `backups/levels/<timestamp>/` | level mods removed with "Remove…" / `--remove-level` |
| `wine/` | own Wine prefix (Linux) |
| `logs/` | game.log, wineboot.log, registry.log |
+13 -1
View File
@@ -6,7 +6,7 @@ Code: `mods/levels/LevelProjectSource` (a `ModSource`), `LevelProjectMod`, `Leve
| | |
|---|---|
| id | `level-<file name>` (lower case, non-alphanumerics → `-`) |
| id | `level-<file name>` (lower case, non-alphanumerics → `-`); names that collide (`My-level` / `my-level`) get `-2`, `-3`… (first in name order keeps the plain id, a suffix never takes an id another file has) |
| name / description | level name, description and author from the project |
| version | first 16 hex digits of the file's SHA-256 (any change of the file rebuilds) |
| order | 60: after Test World (50, chapter 1), before Mirror (75) and the debug view (100), so those also apply to the level |
@@ -24,6 +24,18 @@ the lowest provided chapter fills all missing chapters 1..49 (the map crashes on
chapter WAD), loading pictures as before. To play chapter n: unlock it in the save editor (or
`--unlock <slot> 9 <n> --cursor`), then select it on the world-9 map.
**Temporary play-test copies**: `levels/.playtest/*.llamalevel` are listed too, as
`playtest-<stem>` (`LevelProjectMod.playtest()`, name "… (play-test)", chapter = the project's
own, not counted for other levels' defaults). The editor deletes them after the play-test
(`LevelProjects.clearPlaytest`), see editor-playtest.md. Files from older play-tests in `levels/`
cannot be told apart from saved levels and are left alone (users remove them).
**Removing** (`LevelProjects.remove`, launcher card "Remove…", CLI `--remove-level <id|file>`):
moves the file to `<data>/backups/levels/<yyyyMMdd-HHmmss>/`, takes the id out of `enabledMods`
(`activeMods` stays → the launcher shows "Apply" pending), refused while the game runs and for
play-test copies. The Levels header has "Open levels folder". Screenshots:
`img/mods/levels_remove_dialog.jpg`, `levels_removed.jpg`.
CLI (`EditorCli`): `--editor-import`, `--editor-new`, `--editor-build`, `--import-level`, see
editor-format.md. The registry reads the folder when it is created (restart the app / create a
new `ModRegistry` after importing).
@@ -7,6 +7,8 @@ import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.levels.EditorCli;
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
@@ -124,6 +126,7 @@ public final class Main {
llamamod --editor-import <level.wad|t1l1m001> <project.llamalevel> [--cd <dir>]
llamamod --editor-new <project.llamalevel> [base level] [--name <name>] [--cd <dir>]
llamamod --import-level <project.llamalevel> (into the level folder, shown as a mod)
llamamod --remove-level <id|file> move a level mod's file to backups/levels/
Data folder: -Dllamamod.data=<folder> or LLAMAMOD_DATA (default %s)
"""
.formatted(BuildInfo.display(), AppPaths.defaultRoot()));
@@ -214,6 +217,20 @@ public final class Main {
Path b = st.save(slot, k);
System.out.println("saved" + (b != null ? ", backup: " + b : ""));
}
case "--remove-level" -> {
if (rest.size() != 1) {
throw new UsageException("--remove-level expects one level mod id or file name");
}
LevelProjectMod lm = LevelProjects.find(new ModRegistry(), rest.get(0));
if (lm == null) {
throw new UsageException("no level mod \"" + rest.get(0) + "\" (see --mods, ids level-…)");
}
LevelProjects.Removed r = LevelProjects.remove(
paths, cfg, lm, cfg.installed() && new GameLauncher(paths, cfg).isRunning());
cfg.save(paths.config());
System.out.println("removed " + lm.id() + ": " + lm.file().getFileName() + " moved to " + r.backup()
+ (r.wasActive() ? "\nit was active – run --apply to update the game" : ""));
}
case "--editor-build", "--editor-import", "--editor-new", "--import-level" -> {
if (!args[0].equals("--import-level") && !Edition.editorEnabled()) {
throw new UsageException("This LlamaMod build has no level editor.");
@@ -246,6 +246,15 @@ public final class PathTool implements Tool {
deleteSelected(ctx);
return true;
}
if (k == KeyEvent.VK_ESCAPE) {
if (!ctx.selection().of(PathNode.class).isEmpty()) {
ctx.selection().clear(ctx);
} else {
ctx.setTool("select");
ctx.status("Waypoint editing finished");
}
return true;
}
return false;
}
@@ -16,7 +16,8 @@ import org.joml.Vector3f;
/**
* Play-test straight into the level: replaces the built-in Play (F5) with a run that boots the
* game directly into the level ({@link com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod}),
* adds "Play from camera position" (Shift+F5) and a switch back to the menu-based Play.
* adds "Play from camera position" (Shift+F5), a switch back to the menu-based Play, "Keep
* play-tested level in LlamaMod" and "Add to LlamaMod…".
*/
public final class PlaytestEditorPlugin implements EditorPlugin {
@Override
@@ -36,8 +37,8 @@ public final class PlaytestEditorPlugin implements EditorPlugin {
});
x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> PlaytestRunner.play(c, null))
.enabledWhen(PlaytestEditorPlugin::idle)
.tip("Build the level, install it as a mod and start the game directly in it"
+ " (no menus; your mod selection is restored when the game exits)"));
.tip("Build the level, install a temporary copy and start the game directly in it"
+ " (no menus; when the game exits, the copy is removed and your mod selection restored)"));
x.action(ActionSpec.of(
"level.playHere",
"Level",
@@ -74,6 +75,27 @@ public final class PlaytestEditorPlugin implements EditorPlugin {
.checked(c -> c.prefs().getBool(PlaytestRunner.PREF_QUIT, true))
.enabledWhen(c -> c.prefs().getBool(PlaytestRunner.PREF_BOOT, true))
.tip("In a play-test the pause key (Escape) ends the game at once, back to the editor"));
x.action(ActionSpec.of(
"level.playKeep", "Level", 20, 34, "Keep play-tested level in LlamaMod", null, null, c -> {
c.prefs()
.put(PlaytestRunner.PREF_KEEP, !c.prefs().getBool(PlaytestRunner.PREF_KEEP, false));
c.prefs().save();
})
.checked(c -> c.prefs().getBool(PlaytestRunner.PREF_KEEP, false))
.tip("Off: Play installs a temporary copy that is removed when the game exits. On: the level"
+ " stays in LlamaMod's mod list (Mods → Levels) after testing"));
x.action(ActionSpec.of(
"level.addToLlamaMod",
"Level",
20,
35,
"Add to LlamaMod…",
null,
null,
PlaytestRunner::addToLlamaMod)
.enabledWhen(PlaytestEditorPlugin::idle)
.tip("Copy the level into LlamaMod's level folder as a normal level mod (on its TestLevel"
+ " chapter or original slot), to play it from LlamaMod or share the mod list"));
x.toolbarAction("level.playHere", "build");
x.onOpen(PlaytestRunner::resumeRestore);
}
@@ -1,5 +1,6 @@
package com.eliasmueller.llamamod.editor.tools.playtest;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.ui.Document;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorEnv;
@@ -11,6 +12,7 @@ import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.platform.Json;
@@ -30,15 +32,16 @@ import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
/**
* Play-test with "boot to level": build → install the level mod (as the built-in Play does) →
* apply the user's mods plus {@link BootToLevelMod} (world/chapter of the level, optional player
* start) → start the game, which loads the level without menus. Once the game has exited, the
* user's own selection is applied again (without the boot mod), so LlamaMod's normal Play starts
* at the main menu as before.
* Play-test: build → install a temporary copy of the level in {@code levels/.playtest/} (or, with
* "Keep in LlamaMod", as a normal level mod) → apply the user's mods plus the level and, with
* "boot to level", {@link BootToLevelMod} (world/chapter of the level, optional player start) →
* start the game. Once the game has exited, the user's own selection is applied again and the
* temporary copy is deleted, so LlamaMod looks exactly as before.
*/
public final class PlaytestRunner {
static final String PREF_BOOT = "playtest.boot";
static final String PREF_QUIT = "playtest.quitOnEscape";
static final String PREF_KEEP = "playtest.keep";
private static volatile Thread watcher;
@@ -67,10 +70,6 @@ public final class PlaytestRunner {
return;
}
boolean boot = start != null || ctx.prefs().getBool(PREF_BOOT, true);
if (!boot) {
w.playTest().play();
return;
}
EditorEnv env = ctx.env();
if (!env.config().installed()) {
message(ctx, "The game is not installed. Set it up in LlamaMod first.", JOptionPane.WARNING_MESSAGE);
@@ -88,7 +87,7 @@ public final class PlaytestRunner {
if (!quitRunningGame(ctx, env)) {
return;
}
w.playTest().build(r -> install(w, d, tgt, start));
w.playTest().build(r -> install(w, d, tgt, start, boot));
}
/**
@@ -141,12 +140,34 @@ public final class PlaytestRunner {
JOptionPane.showMessageDialog(ctx.frame(), text, "Play-test", type);
}
/** What the play-test changed, to undo it after the game. */
record Session(List<Mod> userMods, Map<String, String> bootOptions) {}
/**
* What the play-test changed, to undo it after the game: the mods to apply again, the boot mod's
* options and the enabled mods to put back (null = keep the play-test's selection, "Keep in
* LlamaMod").
*/
record Session(List<Mod> userMods, Map<String, String> bootOptions, List<String> enabled) {
Session(List<Mod> userMods, Map<String, String> bootOptions) {
this(userMods, bootOptions, null);
}
}
private static void install(EditorWindow w, Document d, int[] tgt, double[] start) {
/** Where the play-test copy goes: a normal level mod (keep) or a temporary file in levels/.playtest/. */
static PlayTest.Install installFor(EditorContext ctx, Document d, boolean keep) {
PlayTest.Install perm = PlayTest.install(ctx, d);
if (perm == null || keep) {
return perm;
}
String stem = d.file() != null
? d.file().getFileName().toString().replaceFirst("(?i)\\.llamalevel$", "")
: PlayTest.slug(d.title());
Path t = LevelProjects.playtestDir(ctx.env().levelsDir()).resolve(stem + ProjectIO.EXTENSION);
return new PlayTest.Install(perm.snapshot(), t, false);
}
private static void install(EditorWindow w, Document d, int[] tgt, double[] start, boolean boot) {
EditorEnv env = w.env();
PlayTest.Install inst = PlayTest.install(w, d);
boolean keep = w.prefs().getBool(PREF_KEEP, false);
PlayTest.Install inst = installFor(w, d, keep);
if (inst == null) {
return;
}
@@ -158,24 +179,45 @@ public final class PlaytestRunner {
w.runTask(
"Play-test " + d.title(),
p -> {
p.update(0.05, "Installing the level as a mod");
inst.write();
p.update(0.05, keep ? "Installing the level as a mod" : "Installing a temporary copy of the level");
Config cfg = env.config();
// leftovers of an earlier play-test whose restore did not run
LevelProjects.clearPlaytest(env.levelsDir(), cfg);
List<String> prevActive = new ArrayList<>(cfg.activeMods);
prevActive.removeIf(LevelProjects::isPlaytestId);
List<String> prevEnabled = new ArrayList<>(cfg.enabledMods);
inst.write();
ModRegistry reg = new ModRegistry();
LevelProjectMod mine = PlayTest.levelMod(reg, cfg, target, tgt);
List<String> disabled = new ArrayList<>();
boolean userBoot = cfg.enabledMods.contains(BootToLevelMod.ID);
List<Mod> mods = PlayTest.selectionWith(cfg, reg, mine, tgt, disabled, BootToLevelMod.ID);
List<Mod> mods =
PlayTest.selectionWith(cfg, reg, mine, tgt, disabled, boot ? BootToLevelMod.ID : null);
Mod bootMod = reg.get(BootToLevelMod.ID);
if (bootMod == null) {
bootMod = new BootToLevelMod();
}
if (userBoot) {
if (boot && userBoot) {
mods.add(bootMod);
}
cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList());
Map<String, String> before = cfg.modOptions.get(BootToLevelMod.ID);
Map<String, String> saved = before == null ? null : new LinkedHashMap<>(before);
Session session =
keep ? new Session(mods, saved) : new Session(resolve(reg, prevActive), saved, prevEnabled);
if (!boot) {
p.update(0.15, "Applying mods");
env.modManager().apply(mods, p.sub(0.15, 0.85));
env.saveConfig();
p.update(0.86, "Preparing save slot " + slot);
PlayTest.prepareSaveSlot(env, slot, tgt);
p.update(0.9, "Starting the game");
if (!noLaunch) {
writeSession(env, session);
new GameLauncher(env.paths(), cfg).launch(p.sub(0.9, 1));
}
return new Object[] {session, disabled};
}
ModOptions.set(cfg, bootMod, "world", String.valueOf(tgt[0]));
ModOptions.set(cfg, bootMod, "chapter", String.valueOf(tgt[1]));
ModOptions.set(cfg, bootMod, "slot", String.valueOf(slot));
@@ -194,7 +236,6 @@ public final class PlaytestRunner {
env.saveConfig();
p.update(0.86, "Preparing save slot " + slot);
PlayTest.prepareSaveSlot(env, slot, tgt);
Session session = new Session(mods, saved);
p.update(0.9, "Starting the game");
// tests start the game through their own harness (private display and sound)
if (!noLaunch) {
@@ -212,16 +253,48 @@ public final class PlaytestRunner {
d.history().markSaved();
}
env.modsChanged();
w.status("Game started – it loads " + levelName + " directly"
+ (start != null ? " (player at the camera position)" : "")
+ (disabled.isEmpty() ? "" : " · turned off: " + String.join(", ", disabled)));
String off = disabled.isEmpty() ? "" : " · turned off: " + String.join(", ", disabled);
if (boot) {
w.status("Game started – it loads " + levelName + " directly"
+ (start != null ? " (player at the camera position)" : "")
+ off);
} else {
w.status("Game started – " + levelName + " is selected on the map of save slot " + slot + off);
JOptionPane.showMessageDialog(
w,
"<html><body style='width: 400px'><b>The game is starting.</b><br><br>"
+ "In the main menu choose <b>Spiel starten</b> and save slot <b>" + slot
+ "</b> – the map cursor is already on <b>" + esc(levelName)
+ "</b>; press <b>Enter</b> to play.<br><br>"
+ (keep
? "The level stays installed in LlamaMod (Mods → Levels)."
: "The level is installed only for this play-test; use Level → Add to"
+ " LlamaMod to keep it.")
+ "</body></html>",
"Play-test",
JOptionPane.INFORMATION_MESSAGE);
}
if (!noLaunch) {
watch(w, env, session, true);
}
});
}
/** The user's active mods (other mods on the same WAD and Test World on chapter 1 turned off) + the level. */
private static String esc(String s) {
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
private static List<Mod> resolve(ModRegistry reg, List<String> ids) {
List<Mod> out = new ArrayList<>();
for (String id : ids) {
Mod m = reg.get(id);
if (m != null) {
out.add(m);
}
}
return out;
}
/** Waits until the game has exited, then applies the user's selection again (without the boot mod). */
private static void watch(EditorWindow w, EditorEnv env, Session s, boolean launching) {
Thread t = new Thread(
@@ -268,8 +341,12 @@ public final class PlaytestRunner {
} else {
cfg.modOptions.put(BootToLevelMod.ID, new LinkedHashMap<>(s.bootOptions()));
}
if (s.enabled() != null) {
cfg.enabledMods = new ArrayList<>(s.enabled());
}
ModManager mm = env.modManager();
mm.apply(s.userMods(), Progress.NONE);
LevelProjects.clearPlaytest(env.levelsDir(), cfg);
env.saveConfig();
Files.deleteIfExists(sessionFile(env));
}
@@ -287,6 +364,7 @@ public final class PlaytestRunner {
Map<String, Object> m = new LinkedHashMap<>();
m.put("mods", new ArrayList<Object>(s.userMods().stream().map(Mod::id).toList()));
m.put("bootOptions", s.bootOptions() == null ? null : new LinkedHashMap<String, Object>(s.bootOptions()));
m.put("enabled", s.enabled() == null ? null : new ArrayList<Object>(s.enabled()));
Path f = sessionFile(env);
Files.createDirectories(f.getParent());
Files.writeString(f, Json.write(m));
@@ -302,13 +380,10 @@ public final class PlaytestRunner {
if (!(Json.parse(Files.readString(f)) instanceof Map<?, ?> m) || !(m.get("mods") instanceof List<?> ids)) {
return null;
}
List<Mod> mods = new ArrayList<>();
for (Object id : ids) {
Mod mod = reg.get(String.valueOf(id));
if (mod != null) {
mods.add(mod);
}
}
List<Mod> mods = resolve(reg, ids.stream().map(String::valueOf).toList());
List<String> enabled = m.get("enabled") instanceof List<?> en
? new ArrayList<>(en.stream().map(String::valueOf).toList())
: null;
Map<String, String> boot = null;
if (m.get("bootOptions") instanceof Map<?, ?> b) {
boot = new LinkedHashMap<>();
@@ -316,7 +391,7 @@ public final class PlaytestRunner {
boot.put(String.valueOf(e.getKey()), String.valueOf(e.getValue()));
}
}
return new Session(mods, boot);
return new Session(mods, boot, enabled);
} catch (IOException | RuntimeException e) {
return null;
}
@@ -329,6 +404,7 @@ public final class PlaytestRunner {
}
EditorEnv env = ctx.env();
if (!Files.isRegularFile(sessionFile(env))) {
clearStale(env);
return;
}
Session s = readSession(env, new ModRegistry());
@@ -342,4 +418,63 @@ public final class PlaytestRunner {
}
watch(w, env, s, false);
}
/** Removes play-test copies left behind without a pending session (editor closed before the game started). */
static void clearStale(EditorEnv env) {
if (!Files.isDirectory(LevelProjects.playtestDir(env.levelsDir()))
|| new GameLauncher(env.paths(), env.config()).isRunning()) {
return;
}
try {
synchronized (PlaytestRunner.class) {
LevelProjects.clearPlaytest(env.levelsDir(), env.config());
env.saveConfig();
}
} catch (IOException e) {
// tried again next start
}
}
/**
* Level → Add to LlamaMod: writes the current state into the levels folder as a normal level mod
* (reusing the file of the same project) with its TestLevel chapter as the mod's option.
*/
static void addToLlamaMod(EditorContext ctx) {
Document d = ctx.document();
if (d == null || !(ctx instanceof EditorWindow w)) {
return;
}
PlayTest.Install inst = PlayTest.install(ctx, d);
if (inst == null) {
return;
}
EditorEnv env = ctx.env();
int[] tgt = target(d);
w.runTask(
"Add " + d.title() + " to LlamaMod",
p -> {
inst.write();
ModRegistry reg = new ModRegistry();
LevelProjectMod m = tgt == null ? null : PlayTest.levelMod(reg, env.config(), inst.target(), tgt);
env.saveConfig();
return m == null ? d.title() : m.name();
},
res -> {
if (inst.ownFile()) {
d.history().markSaved();
}
env.modsChanged();
String where = tgt == null ? "" : " on " + PlayTest.levelName(tgt);
w.status("Added to LlamaMod: " + inst.target().getFileName() + where);
JOptionPane.showMessageDialog(
w,
"<html><body style='width: 380px'><b>" + esc(String.valueOf(res))
+ "</b> is now a level mod in LlamaMod" + esc(where) + ".<br><br>File: "
+ esc(inst.target().toString())
+ "<br><br>Turn it on under Mods → Levels and click Apply. Remove it there with"
+ " “Remove…”.</body></html>",
"Add to LlamaMod",
JOptionPane.INFORMATION_MESSAGE);
});
}
}
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.platform.Config;
import com.eliasmueller.llamamod.platform.GameLauncher;
import com.eliasmueller.llamamod.save.Ksv;
@@ -198,7 +199,8 @@ public final class PlayTest {
var st = d.scene().settings();
Path own = d.file() == null ? null : d.file().toAbsolutePath().normalize();
for (int i = 1; ; i++) {
Path t = levelsDir.resolve((i == 1 ? stem : stem + "-" + i) + ProjectIO.EXTENSION);
// an existing file whose name differs only in case is the same target (Windows sees one file)
Path t = LevelProjects.resolveIgnoreCase(levelsDir, (i == 1 ? stem : stem + "-" + i) + ProjectIO.EXTENSION);
if (!Files.exists(t) || t.toAbsolutePath().normalize().equals(own)) {
return t;
}
@@ -31,16 +31,23 @@ public final class LevelProjectMod implements Mod {
private final int defaultChapter;
private final String id;
private final boolean playtest;
LevelProjectMod(Path file, byte[] data, ProjectInfo info, int defaultChapter) {
LevelProjectMod(Path file, byte[] data, ProjectInfo info, int defaultChapter, String id, boolean playtest) {
this.file = file;
this.info = info;
this.hash = ProjectIO.sha256(data).substring(0, 16);
this.stamp = stamp(file);
this.defaultChapter = defaultChapter;
this.id = id;
this.playtest = playtest;
}
/** {@code level-<stem>} ({@code playtest-<stem>} for temporary play-test copies), lower case. */
static String baseId(Path file, boolean playtest) {
String fn = file.getFileName().toString();
String stem = fn.substring(0, fn.length() - ProjectIO.EXTENSION.length());
this.id = "level-"
return (playtest ? "playtest-" : "level-")
+ stem.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
}
@@ -48,6 +55,11 @@ public final class LevelProjectMod implements Mod {
return file;
}
/** A temporary copy installed by an editor play-test (removed when the play-test ends). */
public boolean playtest() {
return playtest;
}
public ProjectInfo info() {
return info;
}
@@ -59,7 +71,8 @@ public final class LevelProjectMod implements Mod {
@Override
public String name() {
return info.name().isBlank() ? file.getFileName().toString() : info.name();
String n = info.name().isBlank() ? file.getFileName().toString() : info.name();
return playtest ? n + " (play-test)" : n;
}
@Override
@@ -69,7 +82,9 @@ public final class LevelProjectMod implements Mod {
: "Changes the original level " + info.base() + ".";
String d = info.description().isBlank() ? "" : info.description() + " ";
String by = info.author().isBlank() ? "" : " By " + info.author() + ".";
return d + where + by + " Project: " + file.getFileName();
return playtest
? "Temporary play-test copy from the level editor – removed when the play-test ends. " + where
: d + where + by + " Project: " + file.getFileName();
}
@Override
@@ -16,7 +16,8 @@ import java.util.Set;
/**
* One mod per {@code .llamalevel} file in {@code <data>/levels/} (category "levels"). New levels
* get a default TestLevel chapter: their stored target, or the next free one if another project
* already uses it.
* already uses it. Temporary play-test copies in {@code levels/.playtest/} are listed too, with
* ids {@code playtest-…}.
*/
public final class LevelProjectSource implements ModSource {
private final Path dir;
@@ -33,32 +34,55 @@ public final class LevelProjectSource implements ModSource {
public List<Mod> mods() {
List<Mod> out = new ArrayList<>();
Set<Integer> used = new HashSet<>();
List<Path> levels = LevelProjects.list(dir);
List<Path> tests = LevelProjects.list(LevelProjects.playtestDir(dir));
Set<String> bases = new HashSet<>();
levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false)));
tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true)));
Set<String> ids = new HashSet<>();
for (Path p : LevelProjects.list(dir)) {
byte[] d;
ProjectInfo info;
try {
d = Files.readAllBytes(p);
info = ProjectIO.info(d);
} catch (IOException | RuntimeException e) {
continue;
}
int chapter = 0;
if (info.isNewLevel()) {
chapter = info.targetChapter();
if (chapter < 1 || chapter > 49 || used.contains(chapter)) {
chapter = 2;
while (used.contains(chapter) && chapter < 49) {
chapter++;
}
}
used.add(chapter);
}
LevelProjectMod m = new LevelProjectMod(p, d, info, chapter);
if (ids.add(m.id())) {
out.add(m);
}
for (Path p : levels) {
add(p, false, used, bases, ids, out);
}
for (Path p : tests) {
add(p, true, used, bases, ids, out);
}
return out;
}
private static void add(
Path p, boolean playtest, Set<Integer> used, Set<String> bases, Set<String> ids, List<Mod> out) {
byte[] d;
ProjectInfo info;
try {
d = Files.readAllBytes(p);
info = ProjectIO.info(d);
} catch (IOException | RuntimeException e) {
return;
}
int chapter = 0;
if (info.isNewLevel() && playtest) {
chapter = Math.max(1, Math.min(49, info.targetChapter()));
} else if (info.isNewLevel()) {
chapter = info.targetChapter();
if (chapter < 1 || chapter > 49 || used.contains(chapter)) {
chapter = 2;
while (used.contains(chapter) && chapter < 49) {
chapter++;
}
}
used.add(chapter);
}
// names differing only in case or punctuation share an id: later ones get a suffix no other file has
String base = LevelProjectMod.baseId(p, playtest);
String id = base;
if (!ids.add(id)) {
int i = 2;
while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) {
i++;
}
id = base + "-" + i;
ids.add(id);
}
out.add(new LevelProjectMod(p, d, info, chapter, id, playtest));
}
}
@@ -2,19 +2,30 @@ package com.eliasmueller.llamamod.mods.levels;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModRegistry;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
/** The level project folder {@code <data>/levels/} and importing project files into it. */
/**
* The level project folder {@code <data>/levels/}: importing project files into it, removing them
* (into {@code <data>/backups/levels/<timestamp>/}) and the temporary play-test copies in {@code
* levels/.playtest/}.
*/
public final class LevelProjects {
public static final String DIR = "levels";
public static final String PLAYTEST_DIR = ".playtest";
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
private LevelProjects() {}
@@ -22,6 +33,111 @@ public final class LevelProjects {
return paths.root().resolve(DIR);
}
/** Folder of the temporary play-test copies inside a levels folder. */
public static Path playtestDir(Path levelsDir) {
return levelsDir.resolve(PLAYTEST_DIR);
}
/** The file in dir whose name equals name ignoring case (the first in name order), else dir/name. */
public static Path resolveIgnoreCase(Path dir, String name) {
Path exact = dir.resolve(name);
if (Files.exists(exact)) {
return exact;
}
for (Path p : list(dir)) {
if (p.getFileName().toString().equalsIgnoreCase(name)) {
return p;
}
}
return exact;
}
/**
* Deletes the play-test copies and their entries in the config (enabled mods, options). Active
* mods are left alone: they describe what is applied.
*/
public static void clearPlaytest(Path levelsDir, Config cfg) throws IOException {
Path dir = playtestDir(levelsDir);
if (Files.isDirectory(dir)) {
try (Stream<Path> s = Files.list(dir)) {
for (Path p : s.toList()) {
if (Files.isRegularFile(p)) {
Files.deleteIfExists(p);
}
}
}
try {
Files.deleteIfExists(dir);
} catch (IOException e) {
// not empty (foreign files): keep it
}
}
cfg.enabledMods.removeIf(LevelProjects::isPlaytestId);
cfg.modOptions.keySet().removeIf(LevelProjects::isPlaytestId);
}
public static boolean isPlaytestId(String id) {
return id.startsWith("playtest-");
}
/** A permanent level mod by id or by file (name in the levels folder, or a path). */
public static LevelProjectMod find(ModRegistry reg, String idOrFile) {
Mod byId = reg.get(idOrFile);
if (byId instanceof LevelProjectMod lm && !lm.playtest()) {
return lm;
}
for (Mod m : reg.all()) {
if (m instanceof LevelProjectMod lm && !lm.playtest()) {
String fn = lm.file().getFileName().toString();
if (fn.equals(idOrFile)
|| fn.equals(idOrFile + ProjectIO.EXTENSION)
|| lm.file()
.toAbsolutePath()
.normalize()
.equals(Path.of(idOrFile).toAbsolutePath().normalize())) {
return lm;
}
}
}
return null;
}
/** Outcome of {@link #remove}: where the file went and whether the mod was applied. */
public record Removed(Path backup, boolean wasActive) {}
/**
* Moves a level mod's file to {@code <data>/backups/levels/<timestamp>/} and takes it out of the
* enabled mods. Refused while the game runs. If it was applied, the selection is pending until
* the next apply.
*/
public static Removed remove(AppPaths paths, Config cfg, LevelProjectMod mod, boolean gameRunning)
throws IOException {
if (gameRunning) {
throw new IOException("The game is running – quit it before removing a level.");
}
if (mod.playtest()) {
throw new IOException(
mod.name() + " is a temporary play-test copy; it is removed when the play-test ends.");
}
Path f = mod.file();
if (!Files.isRegularFile(f)) {
throw new IOException(f + " does not exist (any more)");
}
Path dir = paths.backups().resolve(DIR).resolve(LocalDateTime.now().format(TS));
Files.createDirectories(dir);
Path out = dir.resolve(f.getFileName());
for (int i = 2; Files.exists(out); i++) {
out = dir.resolve(i + "-" + f.getFileName());
}
try {
Files.move(f, out, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Files.move(f, out);
}
cfg.enabledMods.remove(mod.id());
return new Removed(out, cfg.activeMods.contains(mod.id()));
}
/** Project files in the folder, sorted by name. */
public static List<Path> list(Path dir) {
List<Path> out = new ArrayList<>();
@@ -4,6 +4,8 @@ import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.ModManager;
import com.eliasmueller.llamamod.mods.ModOption;
import com.eliasmueller.llamamod.mods.ModOptions;
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
import java.awt.Color;
@@ -18,6 +20,8 @@ import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -33,6 +37,7 @@ import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
@@ -51,6 +56,7 @@ final class ModsPanel extends JPanel {
private final MainFrame frame;
private final JPanel list = new JPanel();
private final Map<String, JCheckBox> boxes = new LinkedHashMap<>();
private final List<JButton> removeButtons = new ArrayList<>();
private final List<JComponent> optionInputs = new ArrayList<>();
private final Map<String, JLabel> groupCounts = new LinkedHashMap<>();
private final Set<String> collapsed = new HashSet<>();
@@ -135,6 +141,7 @@ final class ModsPanel extends JPanel {
applyButton.setEnabled(on);
restoreButton.setEnabled(on);
boxes.values().forEach(cb -> cb.setEnabled(on));
removeButtons.forEach(b -> b.setEnabled(on));
optionInputs.forEach(c -> c.setEnabled(on));
}
@@ -144,9 +151,10 @@ final class ModsPanel extends JPanel {
firstReload = false;
list.removeAll();
boxes.clear();
removeButtons.clear();
optionInputs.clear();
groupCounts.clear();
List<Mod> mods = frame.state.registry.all();
List<Mod> mods = mods();
if (mods.isEmpty()) {
JLabel l = new JLabel(Strings.t("mods.none"));
l.setBorder(BorderFactory.createEmptyBorder(20, 4, 20, 4));
@@ -181,6 +189,19 @@ final class ModsPanel extends JPanel {
});
}
/**
* The mods shown: temporary play-test copies of the level editor only while a play-test uses
* them (they are deleted when it ends).
*/
private List<Mod> mods() {
var cfg = frame.state.cfg;
return frame.state.registry.all().stream()
.filter(m -> !(m instanceof LevelProjectMod lm && lm.playtest())
|| cfg.enabledMods.contains(m.id())
|| cfg.activeMods.contains(m.id()))
.toList();
}
private static Map<String, List<Mod>> groups(List<Mod> mods) {
Map<String, List<Mod>> byCat = new TreeMap<>((a, b) -> {
int ia = CATEGORY_ORDER.indexOf(a);
@@ -214,7 +235,18 @@ final class ModsPanel extends JPanel {
count.setForeground(UIManager.getColor("Label.disabledForeground"));
groupCounts.put(category, count);
h.add(title, BorderLayout.WEST);
h.add(count, BorderLayout.EAST);
if (category.equals(Mod.LEVELS)) {
JButton open = linkButton(Strings.t("mods.openLevels"));
open.setToolTipText(Strings.t("mods.openLevelsTip"));
open.addActionListener(e -> openLevelsFolder());
JPanel east = new JPanel(new FlowLayout(FlowLayout.RIGHT, 12, 0));
east.setOpaque(false);
east.add(open);
east.add(count);
h.add(east, BorderLayout.EAST);
} else {
h.add(count, BorderLayout.EAST);
}
h.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
h.addMouseListener(new MouseAdapter() {
@Override
@@ -272,6 +304,13 @@ final class ModsPanel extends JPanel {
title.add(Box.createHorizontalStrut(10));
title.add(exeBadge());
}
boolean playtest = m instanceof LevelProjectMod lm && lm.playtest();
if (playtest) {
title.add(Box.createHorizontalStrut(10));
JLabel b = badge(Strings.t("mods.playtestBadge"), "#3A7BD5");
b.setToolTipText(Strings.t("mods.playtestBadgeTip"));
title.add(b);
}
g = cell(1, 0, 1, GridBagConstraints.WEST);
g.weightx = 1;
c.add(title, g);
@@ -279,7 +318,19 @@ final class ModsPanel extends JPanel {
ver.setToolTipText(Strings.t("mods.idTip", m.id()));
ver.setForeground(UIManager.getColor("Label.disabledForeground"));
ver.setFont(ver.getFont().deriveFont(11.5f));
c.add(ver, cell(2, 0, 1, GridBagConstraints.EAST));
if (m instanceof LevelProjectMod lm && !playtest) {
JButton remove = linkButton(Strings.t("mods.remove"));
remove.setToolTipText(Strings.t("mods.removeTip", lm.file().getFileName()));
remove.addActionListener(e -> remove(lm));
removeButtons.add(remove);
JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0));
right.setOpaque(false);
right.add(remove);
right.add(ver);
c.add(right, cell(2, 0, 1, GridBagConstraints.EAST));
} else {
c.add(ver, cell(2, 0, 1, GridBagConstraints.EAST));
}
JTextArea desc = new JTextArea(m.description());
desc.setLineWrap(true);
desc.setWrapStyleWord(true);
@@ -347,6 +398,83 @@ final class ModsPanel extends JPanel {
return b;
}
private static JLabel badge(String text, String color) {
JLabel b = new JLabel(text);
b.setFont(b.getFont().deriveFont(Font.BOLD, 11f));
b.setOpaque(true);
b.putClientProperty(
"FlatLaf.style",
"arc: 999; background: fade(" + color + ",18%); foreground: "
+ (com.formdev.flatlaf.FlatLaf.isLafDark() ? "lighten(" + color + ",20%)" : color));
b.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
return b;
}
/** Small text button without a frame (secondary actions on cards and headers). */
private static JButton linkButton(String text) {
JButton b = new JButton(text);
b.putClientProperty("JButton.buttonType", "toolBarButton");
b.setFont(b.getFont().deriveFont(12f));
b.setForeground(UIManager.getColor("Component.accentColor"));
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
b.setFocusable(false);
return b;
}
private void openLevelsFolder() {
try {
var dir = LevelProjects.dir(frame.state.paths);
Files.createDirectories(dir);
SettingsPanel.openFolder(dir);
} catch (IOException e) {
frame.error(e);
}
}
/** "Remove…" on a level card: moves the project file to the backups after a confirmation. */
private void remove(LevelProjectMod m) {
if (frame.gameRunning()) {
frame.error(new IllegalStateException(Strings.t("mods.gameRunning")));
return;
}
Object[] options = {Strings.t("mods.removeYes"), Strings.t("mods.removeCancel")};
int r = JOptionPane.showOptionDialog(
frame,
MainFrame.html(
Strings.t(
"mods.removeAsk",
MainFrame.escape(m.name()),
MainFrame.escape(m.file().toString()),
MainFrame.escape(frame.state
.paths
.backups()
.resolve(LevelProjects.DIR)
.toString())),
420),
Strings.t("mods.removeTitle"),
JOptionPane.DEFAULT_OPTION,
JOptionPane.WARNING_MESSAGE,
null,
options,
options[1]);
if (r != 0) {
return;
}
try {
LevelProjects.Removed res =
LevelProjects.remove(frame.state.paths, frame.state.cfg, m, frame.state.launcher.isRunning());
frame.state.saveConfig();
frame.state.reloadMods();
reload();
frame.setStatus(Strings.t(
res.wasActive() ? "mods.removedActive" : "mods.removed",
m.name(),
res.backup().getParent()));
} catch (IOException e) {
frame.error(e);
}
}
// ---------------------------------------------------------------- options
private JPanel optionsPanel(Mod m) {
@@ -500,7 +628,7 @@ final class ModsPanel extends JPanel {
/** In apply order. */
private List<Mod> selected() {
return frame.state.registry.all().stream().filter(this::isSelected).toList();
return mods().stream().filter(this::isSelected).toList();
}
boolean hasPendingChanges() {
@@ -514,7 +642,7 @@ final class ModsPanel extends JPanel {
}
private void updateState() {
for (Map.Entry<String, List<Mod>> g : groups(frame.state.registry.all()).entrySet()) {
for (Map.Entry<String, List<Mod>> g : groups(mods()).entrySet()) {
JLabel l = groupCounts.get(g.getKey());
if (l != null) {
long on = g.getValue().stream().filter(this::isSelected).count();
@@ -57,6 +57,18 @@ mods.category.cheats=Cheats & debug
mods.category.other=Other
mods.exeBadge=modifies groove.exe
mods.exeBadgeTip=This mod changes the game program (groove.exe) in LlamaMod's copy. On Windows, some antivirus programs may wrongly report the changed file.
mods.playtestBadge=temporary play-test
mods.playtestBadgeTip=A copy installed by a play-test in the level editor. It is removed automatically when the play-test ends; use “Add to LlamaMod” in the editor to keep a level.
mods.remove=Remove…
mods.removeTip=Remove this level from LlamaMod (the file {0} is moved to the backups)
mods.removeTitle=Remove level
mods.removeAsk=<b>Remove the level “{0}” from LlamaMod?</b><br><br>The file<br><tt>{1}</tt><br>is moved to a new folder in<br><tt>{2}</tt><br>(nothing is deleted). If this is also your editor project, open it from there to work on it again.
mods.removeYes=Remove
mods.removeCancel=Cancel
mods.removed=Removed “{0}” – the file is in {1}.
mods.removedActive=Removed “{0}” – the file is in {1}. It was active: click “Apply” to update the game.
mods.openLevels=Open levels folder
mods.openLevelsTip=The folder with the level projects shown here (every .llamalevel file in it is a level mod)
av.noticeTitle=Note about antivirus software
av.notice=The selected mods (<b>{0}</b>) add extra code to <b>groove.exe</b> in LlamaMod''s private game copy.<br><br>Some antivirus programs (e.g. Windows Defender) may report this changed file as a threat and quarantine it. This is a false positive: LlamaMod only patches its own copy, your original game files are never touched.<br><br>If that happens, add the LlamaMod data folder as an exclusion in your antivirus program:
@@ -0,0 +1,187 @@
package com.eliasmueller.llamamod.editor.tools.playtest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.io.ProjectIO;
import com.eliasmueller.llamamod.editor.ui.EditorEnv;
import com.eliasmueller.llamamod.mods.GameData;
import com.eliasmueller.llamamod.mods.Mod;
import com.eliasmueller.llamamod.mods.levels.LevelProjectMod;
import com.eliasmueller.llamamod.mods.levels.LevelProjectSource;
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
import com.eliasmueller.llamamod.platform.AppPaths;
import com.eliasmueller.llamamod.platform.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Play-test copies are temporary, "keep" copies stay, and the launcher's Remove moves files to the backups. */
class PlaytestLifecycleTest {
@TempDir
Path tmp;
private AppPaths paths;
private Config cfg;
private EditorEnv env;
private Path levels;
private byte[] project;
@BeforeEach
void setUp() throws IOException {
Bases cd = Bases.fromCd(GameData.cd());
Project p = Project.create(cd, "new", "t1l1m001");
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), 4);
project = ProjectIO.write(p, cd);
paths = new AppPaths(tmp);
// a minimal installed CD: applying the user's (empty) selection needs no game data
Files.createDirectories(paths.originalCd().resolve("wads"));
Files.write(paths.originalCd().resolve("wads/t1l1m001.wad"), new byte[] {1});
cfg = new Config();
env = new EditorEnv(paths, cfg, null, GameData.cd(), true);
levels = LevelProjects.dir(paths);
}
private Path put(Path dir, String name) throws IOException {
Files.createDirectories(dir);
return Files.write(dir.resolve(name), project);
}
private LevelProjectMod mod(String id) {
for (Mod m : new LevelProjectSource(levels).mods()) {
if (m.id().equals(id)) {
return (LevelProjectMod) m;
}
}
return null;
}
@Test
void playtestCopyIsRemovedWhenThePlaytestEnds() throws IOException {
put(levels, "mine.llamalevel");
Path copy = put(LevelProjects.playtestDir(levels), "mine.llamalevel");
LevelProjectMod t = mod("playtest-mine");
assertNotNull(t);
assertTrue(t.playtest());
assertTrue(t.name().endsWith("(play-test)"));
assertEquals("4", t.options().getFirst().defaultValue(), "own chapter, not shifted by the saved level");
assertFalse(mod("level-mine").playtest());
cfg.enabledMods = new ArrayList<>(List.of("fog", "playtest-mine"));
cfg.modOptions.put("playtest-mine", Map.of("chapter", "4"));
PlaytestRunner.Session s = new PlaytestRunner.Session(List.of(), null, List.of("fog", "level-mine"));
PlaytestRunner.writeSession(env, s);
PlaytestRunner.restore(env, s);
assertFalse(Files.exists(copy));
assertFalse(Files.exists(LevelProjects.playtestDir(levels)));
assertEquals(List.of("fog", "level-mine"), cfg.enabledMods);
assertFalse(cfg.modOptions.containsKey("playtest-mine"));
assertFalse(Files.exists(PlaytestRunner.sessionFile(env)));
assertNull(mod("playtest-mine"));
assertNotNull(mod("level-mine"), "the saved level stays");
assertEquals(List.of("fog", "level-mine"), Config.load(paths.config()).enabledMods);
}
@Test
void pendingSessionKeepsTheSelectionToRestore() throws IOException {
PlaytestRunner.Session s = new PlaytestRunner.Session(List.of(), null, List.of("a", "b"));
PlaytestRunner.writeSession(env, s);
assertEquals(
List.of("a", "b"),
PlaytestRunner.readSession(env, new com.eliasmueller.llamamod.mods.ModRegistry())
.enabled());
}
@Test
void staleCopiesAreClearedOnEditorStart() throws IOException {
Path copy = put(LevelProjects.playtestDir(levels), "old.llamalevel");
PlaytestRunner.clearStale(env);
assertFalse(Files.exists(copy));
}
@Test
void keptLevelStays() throws IOException {
Path f = put(levels, "kept.llamalevel");
cfg.enabledMods = new ArrayList<>(List.of("level-kept"));
PlaytestRunner.restore(env, new PlaytestRunner.Session(List.of(), null));
assertTrue(Files.exists(f));
assertEquals(List.of("level-kept"), cfg.enabledMods);
assertNotNull(mod("level-kept"));
}
@Test
void removeMovesTheFileToTheBackups() throws IOException {
Path f = put(levels, "gone.llamalevel");
LevelProjectMod m = mod("level-gone");
cfg.enabledMods = new ArrayList<>(List.of("level-gone", "fog"));
cfg.activeMods = new ArrayList<>(List.of("level-gone", "fog"));
LevelProjects.Removed r = LevelProjects.remove(paths, cfg, m, false);
assertFalse(Files.exists(f));
assertTrue(r.wasActive());
assertEquals(paths.backups().resolve("levels"), r.backup().getParent().getParent());
assertTrue(Files.isRegularFile(r.backup()));
assertEquals("gone.llamalevel", r.backup().getFileName().toString());
assertEquals(List.of("fog"), cfg.enabledMods);
assertEquals(List.of("level-gone", "fog"), cfg.activeMods, "applied state changes only with Apply");
assertNull(mod("level-gone"));
}
@Test
void removeIsRefusedWhileTheGameRuns() throws IOException {
Path f = put(levels, "busy.llamalevel");
cfg.enabledMods = new ArrayList<>(List.of("level-busy"));
IOException e =
assertThrows(IOException.class, () -> LevelProjects.remove(paths, cfg, mod("level-busy"), true));
assertTrue(e.getMessage().contains("game is running"), e.getMessage());
assertTrue(Files.exists(f));
assertEquals(List.of("level-busy"), cfg.enabledMods);
put(LevelProjects.playtestDir(levels), "t.llamalevel");
LevelProjectMod t = mod("playtest-t");
assertThrows(
IOException.class,
() -> LevelProjects.remove(paths, cfg, t, false),
"play-test copies go by themselves");
}
@Test
void findByIdOrFileName() throws IOException {
put(levels, "Some Level.llamalevel");
put(LevelProjects.playtestDir(levels), "some-level.llamalevel");
var reg = new java.util.LinkedHashMap<String, Mod>();
new LevelProjectSource(levels).mods().forEach(m -> reg.put(m.id(), m));
assertEquals(List.of("level-some-level", "playtest-some-level"), List.copyOf(reg.keySet()));
}
/** Regression: "My-level" (old play-test) and "my-level" (new slug) both had the id level-my-level. */
@Test
void namesDifferingInCaseGetUniqueIds() throws IOException {
put(levels, "My-level.llamalevel");
put(levels, "my-level.llamalevel");
put(levels, "my-level-2.llamalevel");
List<Mod> mods = new LevelProjectSource(levels).mods();
assertEquals(3, mods.size());
Map<String, String> byFile = new java.util.TreeMap<>();
for (Mod m : mods) {
byFile.put(((LevelProjectMod) m).file().getFileName().toString(), m.id());
}
assertEquals("level-my-level", byFile.get("My-level.llamalevel"));
assertEquals("level-my-level-2", byFile.get("my-level-2.llamalevel"), "an existing id stays stable");
assertEquals("level-my-level-3", byFile.get("my-level.llamalevel"));
assertEquals(
levels.resolve("My-level.llamalevel"), LevelProjects.resolveIgnoreCase(levels, "MY-LEVEL.llamalevel"));
assertEquals(levels.resolve("new.llamalevel"), LevelProjects.resolveIgnoreCase(levels, "new.llamalevel"));
}
}