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:
1 parent
2386443fac
commit
1c8b766454
24 files changed
+823
-87
No files matched your search
@@ -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*).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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)).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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…**.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -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);
|
||||
}
|
||||
|
||||
+166
-31
@@ -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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
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:
|
||||
|
||||
+187
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user