testlevel chapters 2-31 overwrite game memory (coin counter and flag rows end after world 8: progress, language, options): level projects, play-tests and the arena use chapters 1 and 32-49, old settings migrate; level tally counts the level's own coins and secrets
This commit is contained in:
1 parent
987b0104fa
commit
d6b852a1b6
35 files changed
+1015
-175
No files matched your search
+8
-2
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
|
||||
import com.eliasmueller.llamamod.editor.ui.EditorContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
@@ -38,6 +39,7 @@ import javax.swing.JScrollPane;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SpinnerListModel;
|
||||
|
||||
/** Level → Settings: name, chapter, grid size, death plane, WFPC flags, object pool (one undo step). */
|
||||
final class LevelSettingsDialog {
|
||||
@@ -80,9 +82,13 @@ final class LevelSettingsDialog {
|
||||
"Base level",
|
||||
new JLabel(st.base() + " (object code, textures, sounds)"),
|
||||
"Fixed when the project is created (the project stores only the differences to it)");
|
||||
JSpinner chapter = Ui.number(Math.max(1, st.targetChapter()), 1, 49, 1, "0", v -> {});
|
||||
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
|
||||
chapter.setValue(TestLevelChapters.migrate(st.targetChapter()));
|
||||
JLabel used = new JLabel(usedChapters(ctx, st.targetChapter()));
|
||||
f.row("TestLevel chapter", chapter, "New levels replace this chapter of world 9 (TestLevel)");
|
||||
f.row(
|
||||
"TestLevel chapter",
|
||||
chapter,
|
||||
"New levels replace this chapter of world 9 (TestLevel): 32–49, or 1 (the Test World's)");
|
||||
f.full(used);
|
||||
chapter.setEnabled(st.isNewLevel());
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.eliasmueller.llamamod.platform.Config;
|
||||
import com.eliasmueller.llamamod.platform.GameLauncher;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import com.eliasmueller.llamamod.platform.Progress;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import com.eliasmueller.llamamod.save.Worlds;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -50,7 +51,7 @@ public final class PlaytestRunner {
|
||||
/** World/chapter of a project: TestLevel chapter, or the original's place; null if not on the map. */
|
||||
public static int[] target(Document d) {
|
||||
if (d.isNewLevel()) {
|
||||
return new int[] {9, Math.max(1, d.scene().settings().targetChapter())};
|
||||
return new int[] {9, TestLevelChapters.migrate(d.scene().settings().targetChapter())};
|
||||
}
|
||||
Matcher m = Pattern.compile("t1l(\\d)m(\\d{3})").matcher(d.base().toLowerCase(Locale.ROOT));
|
||||
if (m.matches()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
|
||||
import com.eliasmueller.llamamod.editor.io.ProjectInfo;
|
||||
import com.eliasmueller.llamamod.editor.io.Templates;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
@@ -28,7 +29,7 @@ import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.SpinnerListModel;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
/** New level: name, base level (object types, textures, sky, music), template, TestLevel chapter. */
|
||||
@@ -51,7 +52,8 @@ final class NewLevelDialog extends JDialog {
|
||||
base.setSelectedItem(l);
|
||||
}
|
||||
}
|
||||
JSpinner chapter = new JSpinner(new SpinnerNumberModel(nextFreeChapter(w), 1, 49, 1));
|
||||
JSpinner chapter = new JSpinner(new SpinnerListModel(TestLevelChapters.all()));
|
||||
chapter.setValue(nextFreeChapter(w));
|
||||
PicturePanel pic = new PicturePanel(300, 170);
|
||||
Runnable updatePic = () -> {
|
||||
OriginalLevels.Level l = (OriginalLevels.Level) base.getSelectedItem();
|
||||
@@ -75,7 +77,8 @@ final class NewLevelDialog extends JDialog {
|
||||
row,
|
||||
"TestLevel chapter",
|
||||
chapter,
|
||||
"The level replaces this chapter of world 9 (TestLevel) when it is enabled");
|
||||
"The level replaces this chapter of world 9 (TestLevel) when it is enabled (32–49; 1 is the Test"
|
||||
+ " World's)");
|
||||
JPanel templates = new JPanel();
|
||||
templates.setLayout(new BoxLayout(templates, BoxLayout.Y_AXIS));
|
||||
ButtonGroup g = new ButtonGroup();
|
||||
@@ -161,7 +164,7 @@ final class NewLevelDialog extends JDialog {
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
/** First TestLevel chapter from 2 that no project in the levels folder uses. */
|
||||
/** First usable TestLevel chapter from 32 that no project in the levels folder uses. */
|
||||
static int nextFreeChapter(EditorWindow w) {
|
||||
Set<Integer> used = new HashSet<>();
|
||||
for (Path p : LevelProjects.list(w.env().levelsDir())) {
|
||||
@@ -174,11 +177,7 @@ final class NewLevelDialog extends JDialog {
|
||||
// skip broken files
|
||||
}
|
||||
}
|
||||
int c = 2;
|
||||
while (used.contains(c) && c < 49) {
|
||||
c++;
|
||||
}
|
||||
return c;
|
||||
return TestLevelChapters.pick(TestLevelChapters.FIRST, used);
|
||||
}
|
||||
|
||||
Result result() {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Command;
|
||||
import com.eliasmueller.llamamod.platform.Json;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
@@ -592,7 +593,7 @@ public final class ProjectController {
|
||||
? String.format(
|
||||
Locale.ROOT,
|
||||
"T1L9M%03d",
|
||||
Math.max(1, d.scene().settings().targetChapter()))
|
||||
TestLevelChapters.migrate(d.scene().settings().targetChapter()))
|
||||
: d.base();
|
||||
fc.setSelectedFile(new File(fc.getCurrentDirectory(), stem + ".wad"));
|
||||
if (fc.showSaveDialog(w) != JFileChooser.APPROVE_OPTION) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.eliasmueller.llamamod.format.stpcmerge;
|
||||
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -87,6 +89,41 @@ public final class TextMerge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TEXT chunk {@code orig} with the pages, entries and animations of {@code tex} (usually
|
||||
* {@code Textures.of(orig)} extended by {@link #merge}); palettes and the rest are kept.
|
||||
*/
|
||||
public static byte[] rebuild(byte[] orig, Textures tex) {
|
||||
Textures old = Textures.of(orig);
|
||||
int head = 8 + 8 * old.entries.size();
|
||||
for (Anim a : old.anims) {
|
||||
head += 3 + a.data().length;
|
||||
}
|
||||
TextChunk tc = TextChunk.parse(orig);
|
||||
byte[] rest = Arrays.copyOfRange(tc.tail, head, tc.tail.length);
|
||||
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
|
||||
.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(tex.entries.size());
|
||||
for (TextChunk.TextureEntry e : tex.entries) {
|
||||
b.putShort((short) e.flags())
|
||||
.put((byte) e.page())
|
||||
.put((byte) e.page2())
|
||||
.put((byte) e.u0())
|
||||
.put((byte) e.u1())
|
||||
.put((byte) e.v0())
|
||||
.put((byte) e.v1());
|
||||
}
|
||||
b.putInt(tex.anims.size());
|
||||
for (Anim a : tex.anims) {
|
||||
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
|
||||
}
|
||||
b.put(rest);
|
||||
tc.pages.clear();
|
||||
tc.pages.addAll(tex.pages);
|
||||
tc.tail = Arrays.copyOf(b.array(), b.position());
|
||||
return tc.build();
|
||||
}
|
||||
|
||||
/** Result: map[srcIndex] = target index (−1 = not needed), counts of added items. */
|
||||
public record Result(int[] map, int addedPages, int addedEntries, int addedAnims, int reusedEntries) {}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.eliasmueller.llamamod.mods.arena;
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.TextChunk;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.DialogMerge;
|
||||
import com.eliasmueller.llamamod.format.stpcmerge.SoundMerge;
|
||||
@@ -106,10 +105,6 @@ final class ArenaImport {
|
||||
StpcMerge.Analysis tgt = new StpcMerge.Analysis(new StpcImage(wad.require("STPC"), lv.wfpc()), troots);
|
||||
byte[] text = wad.require("TEXT");
|
||||
TextMerge.Textures tex = TextMerge.Textures.of(text);
|
||||
int origTail = 8 + 8 * tex.entries.size();
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
origTail += 3 + a.data().length;
|
||||
}
|
||||
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
|
||||
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
|
||||
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
|
||||
@@ -143,40 +138,13 @@ final class ArenaImport {
|
||||
Wad out = new Wad(wad.chunks());
|
||||
out.set("STPC", res.stpc());
|
||||
out.set("MAP", map.build());
|
||||
out.set("TEXT", text(text, tex, origTail));
|
||||
out.set("TEXT", TextMerge.rebuild(text, tex));
|
||||
if (wad.get("SMPC") != null) {
|
||||
out.set("SMPC", SoundMerge.build(snd));
|
||||
}
|
||||
return new Merged(out.toBytes(), codes, res.stats());
|
||||
}
|
||||
|
||||
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
|
||||
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
|
||||
TextChunk tc = TextChunk.parse(orig);
|
||||
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
|
||||
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
|
||||
.order(ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(tex.entries.size());
|
||||
for (TextChunk.TextureEntry e : tex.entries) {
|
||||
b.putShort((short) e.flags())
|
||||
.put((byte) e.page())
|
||||
.put((byte) e.page2())
|
||||
.put((byte) e.u0())
|
||||
.put((byte) e.u1())
|
||||
.put((byte) e.v0())
|
||||
.put((byte) e.v1());
|
||||
}
|
||||
b.putInt(tex.anims.size());
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
|
||||
}
|
||||
b.put(rest);
|
||||
tc.pages.clear();
|
||||
tc.pages.addAll(tex.pages);
|
||||
tc.tail = Arrays.copyOf(b.array(), b.position());
|
||||
return tc.build();
|
||||
}
|
||||
|
||||
private static void putI32(byte[] d, int o, int v) {
|
||||
ByteBuffer.wrap(d, o, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(v);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelTally;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
@@ -53,7 +55,29 @@ public final class ArenaMod implements Mod {
|
||||
ModOption.integer(
|
||||
"energy", "Llama energy", "Energy cap of the llama in the arena (5 = normal).", 4, 1, 5),
|
||||
ModOption.integer("coins", "Coins per hit", "Coins that pop out per hit (5 more at the end).", 1, 0, 5),
|
||||
ModOption.integer("chapter", "TestLevel chapter", "Chapter of world 9 the arena replaces.", 8, 2, 8));
|
||||
chapterOption());
|
||||
}
|
||||
|
||||
static final int DEFAULT_CHAPTER = TestLevelChapters.LAST;
|
||||
|
||||
/** TestLevel chapters 32–49 ({@link TestLevelChapters}); a stored 2–8 falls back to 49. */
|
||||
static ModOption chapterOption() {
|
||||
ModOption.Choice[] cs = new ModOption.Choice[TestLevelChapters.LAST - TestLevelChapters.FIRST + 1];
|
||||
for (int c = TestLevelChapters.FIRST; c <= TestLevelChapters.LAST; c++) {
|
||||
cs[c - TestLevelChapters.FIRST] = ModOption.of(Integer.toString(c), Integer.toString(c));
|
||||
}
|
||||
return ModOption.choice(
|
||||
"chapter",
|
||||
"TestLevel chapter",
|
||||
"Chapter of world 9 the arena replaces (32–49; chapter 1 is the Test World's).",
|
||||
Integer.toString(DEFAULT_CHAPTER),
|
||||
cs);
|
||||
}
|
||||
|
||||
/** Coins the arena can give beyond its placed ones: hit and death coins of every alligator, exit bonus. */
|
||||
static int dynamicCoins(ArenaScripts.Settings s) {
|
||||
int perGator = (s.lives() - 1) * s.coinsPerHit() + 5;
|
||||
return ArenaScripts.totalAlligators(s.waves()) * perGator + 5;
|
||||
}
|
||||
|
||||
/** After Test World (50) and level projects (60), so a clash with them is reported here. */
|
||||
@@ -95,7 +119,8 @@ public final class ArenaMod implements Mod {
|
||||
}
|
||||
ctx.progress(0.0, "building alligator arena");
|
||||
byte[] base = ctx.read(LevelBuilder.BASE_WAD);
|
||||
byte[] wad = new Arena(settings(ctx))
|
||||
ArenaScripts.Settings settings = settings(ctx);
|
||||
byte[] wad = new Arena(settings)
|
||||
.buildWad(
|
||||
base,
|
||||
rel -> {
|
||||
@@ -106,7 +131,7 @@ public final class ArenaMod implements Mod {
|
||||
}
|
||||
},
|
||||
scriptOpts);
|
||||
ctx.write(target, wad);
|
||||
ctx.write(target, LevelTally.counted(wad, dynamicCoins(settings)));
|
||||
ctx.progress(0.9, "loading picture");
|
||||
ctx.write("T19" + chapter + ".COR", loadingPicture(chapter));
|
||||
ctx.progress(1.0, "arena done");
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod;
|
||||
import com.eliasmueller.llamamod.mods.boottolevel.StartOverride;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -129,14 +130,22 @@ public final class LevelProjectMod implements Mod {
|
||||
if (!info.isNewLevel()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(ModOption.integer(
|
||||
"chapter",
|
||||
"TestLevel chapter",
|
||||
"Chapter of world 9 (TestLevel) the level replaces. Chapter 1 is also used by the Test World mod;"
|
||||
+ " each level needs its own chapter. Unlock it on the Save games tab.",
|
||||
defaultChapter,
|
||||
1,
|
||||
49));
|
||||
return List.of(chapterOption(
|
||||
"Chapter of world 9 (TestLevel) the level replaces: 32–49, or 1 (the Test World's chapter). Each"
|
||||
+ " level needs its own chapter. Unlock it on the Save games tab.",
|
||||
defaultChapter));
|
||||
}
|
||||
|
||||
/**
|
||||
* TestLevel chapter choice: the usable chapters ({@link TestLevelChapters}); a stored value
|
||||
* outside them (2–31 from older versions) falls back to {@code def}.
|
||||
*/
|
||||
private static ModOption chapterOption(String description, int def) {
|
||||
ModOption.Choice[] cs = TestLevelChapters.all().stream()
|
||||
.map(c -> ModOption.of(
|
||||
Integer.toString(c), c == TestLevelChapters.TEST_WORLD ? "1 (Test World)" : "" + c))
|
||||
.toArray(ModOption.Choice[]::new);
|
||||
return ModOption.choice("chapter", "TestLevel chapter", description, Integer.toString(def), cs);
|
||||
}
|
||||
|
||||
/** CD path of the WAD this mod writes. */
|
||||
@@ -160,7 +169,8 @@ public final class LevelProjectMod implements Mod {
|
||||
throw new IOException("Level \"" + name() + "\": " + target + " is already changed by another mod ("
|
||||
+ (info.isNewLevel()
|
||||
? "Test World or another level on TestLevel chapter " + chapter
|
||||
+ "). Choose a free chapter in the options of \"" + name() + "\""
|
||||
+ "). Choose a free chapter in the options of \"" + name()
|
||||
+ "\" or turn one of them off"
|
||||
: "two mods change the same level). Turn one of them off")
|
||||
+ ".");
|
||||
}
|
||||
@@ -174,6 +184,9 @@ public final class LevelProjectMod implements Mod {
|
||||
throw new IOException("Level \"" + name() + "\": " + bad + ". Move the Llama onto walkable ground in"
|
||||
+ " the level editor.");
|
||||
}
|
||||
if (info.isNewLevel()) {
|
||||
wad = LevelTally.counted(wad);
|
||||
}
|
||||
ctx.write(target, wad);
|
||||
if (info.isNewLevel()) {
|
||||
ctx.progress(0.9, "loading picture");
|
||||
|
||||
@@ -5,18 +5,21 @@ import com.eliasmueller.llamamod.editor.io.ProjectInfo;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModSource;
|
||||
import com.eliasmueller.llamamod.platform.AppPaths;
|
||||
import com.eliasmueller.llamamod.save.TestLevelChapters;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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. Temporary play-test copies in {@code levels/.playtest/} are listed too, with
|
||||
* get a default TestLevel chapter: their stored target, or the next free usable one if another
|
||||
* project already uses it or it is not usable ({@link TestLevelChapters}; old projects on 2–31). Temporary play-test copies in {@code levels/.playtest/} are listed too, with
|
||||
* ids {@code playtest-…}.
|
||||
*/
|
||||
public final class LevelProjectSource implements ModSource {
|
||||
@@ -32,57 +35,57 @@ public final class LevelProjectSource implements ModSource {
|
||||
|
||||
@Override
|
||||
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 : levels) {
|
||||
add(p, false, used, bases, ids, out);
|
||||
List<Entry> all = new ArrayList<>();
|
||||
levels.forEach(p -> read(p, false, all));
|
||||
tests.forEach(p -> read(p, true, all));
|
||||
// usable stored chapters first (first file wins), so a moved old project never displaces them
|
||||
Map<Entry, Integer> chapters = new HashMap<>();
|
||||
Set<Integer> used = new HashSet<>();
|
||||
for (Entry e : all) {
|
||||
int c = e.info.targetChapter();
|
||||
if (e.info.isNewLevel() && !e.playtest && TestLevelChapters.usable(c) && used.add(c)) {
|
||||
chapters.put(e, c);
|
||||
}
|
||||
}
|
||||
for (Path p : tests) {
|
||||
add(p, true, used, bases, ids, out);
|
||||
List<Mod> out = new ArrayList<>();
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (Entry e : all) {
|
||||
int chapter = 0;
|
||||
if (e.info.isNewLevel() && e.playtest) {
|
||||
chapter = TestLevelChapters.migrate(e.info.targetChapter());
|
||||
} else if (e.info.isNewLevel()) {
|
||||
chapter = chapters.computeIfAbsent(e, k -> TestLevelChapters.pick(k.info.targetChapter(), used));
|
||||
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(e.file, e.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(e.file, e.data, e.info, chapter, id, e.playtest));
|
||||
}
|
||||
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;
|
||||
private record Entry(Path file, boolean playtest, byte[] data, ProjectInfo info) {}
|
||||
|
||||
private static void read(Path p, boolean playtest, List<Entry> out) {
|
||||
try {
|
||||
d = Files.readAllBytes(p);
|
||||
info = ProjectIO.info(d);
|
||||
byte[] d = Files.readAllBytes(p);
|
||||
out.add(new Entry(p, playtest, d, ProjectIO.info(d)));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return;
|
||||
// unreadable or broken: not listed
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.eliasmueller.llamamod.mods.levels;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.format.Wad;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The level tally's totals ("Münzen gesammelt n / total", "Geheimnisse entdeckt n / total"): chunk
|
||||
* LNFO = u32 coins, u32 secrets (→ 0x5ff070 / 0x5ff060, FUN_0042ab10). Levels built on a base keep
|
||||
* the base's numbers (Village: 55, 2) unless they are recounted here.
|
||||
*/
|
||||
public final class LevelTally {
|
||||
private LevelTally() {}
|
||||
|
||||
/**
|
||||
* Placed coins (Coin = 1, Coin5 = 5) and Secret strats of a level WAD. Other coin sources
|
||||
* (CoinLine, CoinCircle, enemies' EnemyCoins …) are not counted.
|
||||
*/
|
||||
public static int[] count(byte[] wad) {
|
||||
Level lv = Level.parse(wad);
|
||||
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
|
||||
int coins = n(t, "Coin") + 5 * n(t, "Coin5");
|
||||
return new int[] {coins, n(t, "Secret")};
|
||||
}
|
||||
|
||||
private static int n(Map<String, List<MapChunk.Strat>> t, String type) {
|
||||
List<MapChunk.Strat> l = t.get(type);
|
||||
return l == null ? 0 : l.size();
|
||||
}
|
||||
|
||||
/** {@code wad} with LNFO set to {@link #count} plus {@code extraCoins}; unchanged without LNFO. */
|
||||
public static byte[] counted(byte[] wad, int extraCoins) {
|
||||
int[] c = count(wad);
|
||||
return withTotals(wad, c[0] + extraCoins, c[1]);
|
||||
}
|
||||
|
||||
public static byte[] counted(byte[] wad) {
|
||||
return counted(wad, 0);
|
||||
}
|
||||
|
||||
/** {@code wad} with LNFO = (coins, secrets); unchanged if it has no LNFO chunk. */
|
||||
public static byte[] withTotals(byte[] wad, int coins, int secrets) {
|
||||
Wad w = Wad.parse(wad);
|
||||
byte[] old = w.get("LNFO");
|
||||
if (old == null || old.length < 8) {
|
||||
return wad;
|
||||
}
|
||||
byte[] ln = old.clone();
|
||||
ByteBuffer.wrap(ln).order(ByteOrder.LITTLE_ENDIAN).putInt(0, coins).putInt(4, secrets);
|
||||
w.set("LNFO", ln);
|
||||
return w.toBytes();
|
||||
}
|
||||
}
|
||||
@@ -412,10 +412,6 @@ public final class Gallery {
|
||||
StpcMerge.Analysis tgt = new StpcMerge.Analysis(ti, roots);
|
||||
byte[] text = wad.require("TEXT");
|
||||
TextMerge.Textures tex = TextMerge.Textures.of(text);
|
||||
int origTail = 8 + 8 * tex.entries.size();
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
origTail += 3 + a.data().length;
|
||||
}
|
||||
List<SoundMerge.Sample> snd = SoundMerge.parse(wad.get("SMPC"));
|
||||
DialogMerge.Texts texts = wad.get("LGPC") == null ? null : DialogMerge.Texts.parse(wad.get("LGPC"));
|
||||
DialogMerge.Speech speech = DialogMerge.Speech.parse(wad.get("SRPC"));
|
||||
@@ -463,34 +459,7 @@ public final class Gallery {
|
||||
Wad out = new Wad(wad.chunks());
|
||||
out.set("STPC", res.stpc());
|
||||
out.set("MAP", map.build());
|
||||
out.set("TEXT", text(text, tex, origTail));
|
||||
out.set("TEXT", TextMerge.rebuild(text, tex));
|
||||
return new Merged(out.toBytes(), codes, res.stats());
|
||||
}
|
||||
|
||||
/** TEXT chunk with the (extended) pages, entries and animations of tex; the rest is kept. */
|
||||
private static byte[] text(byte[] orig, TextMerge.Textures tex, int origTailHead) {
|
||||
TextChunk tc = TextChunk.parse(orig);
|
||||
byte[] rest = Arrays.copyOfRange(tc.tail, origTailHead, tc.tail.length);
|
||||
ByteBuffer b = ByteBuffer.allocate(8 + 8 * tex.entries.size() + tex.anims.size() * 64 + rest.length + 1024)
|
||||
.order(java.nio.ByteOrder.LITTLE_ENDIAN);
|
||||
b.putInt(tex.entries.size());
|
||||
for (TextChunk.TextureEntry e : tex.entries) {
|
||||
b.putShort((short) e.flags())
|
||||
.put((byte) e.page())
|
||||
.put((byte) e.page2())
|
||||
.put((byte) e.u0())
|
||||
.put((byte) e.u1())
|
||||
.put((byte) e.v0())
|
||||
.put((byte) e.v1());
|
||||
}
|
||||
b.putInt(tex.anims.size());
|
||||
for (TextMerge.Anim a : tex.anims) {
|
||||
b.putShort((short) a.texture()).put((byte) a.type()).put(a.data());
|
||||
}
|
||||
b.put(rest);
|
||||
tc.pages.clear();
|
||||
tc.pages.addAll(tex.pages);
|
||||
tc.tail = Arrays.copyOf(b.array(), b.position());
|
||||
return tc.build();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.eliasmueller.llamamod.format.CorEncoder;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelTally;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -59,7 +60,7 @@ public final class TestWorldMod implements Mod {
|
||||
}
|
||||
},
|
||||
Map.of());
|
||||
ctx.write(OUT_WAD, wad);
|
||||
ctx.write(OUT_WAD, LevelTally.counted(wad));
|
||||
ctx.progress(0.9, "loading picture");
|
||||
ctx.write(LOADING_PICTURE, loadingPicture());
|
||||
ctx.progress(1.0, "test world done");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.eliasmueller.llamamod.save;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* TestLevel (world 9) chapters a mod level may occupy. The game has per-chapter arrays only for
|
||||
* worlds 1–8: the coin counter u32 0x584F1C[world·6 + chapter] and the flag byte
|
||||
* 0x584EC8[world·6 + chapter]. For world 9 both run into other globals, which the game zeroes on
|
||||
* level start and overwrites with coins or flags:
|
||||
*
|
||||
* <pre>
|
||||
* coin slot 0x584FF4 + 4c: c=0 counter var 0xC0, 1 unused, 2 counter var 0xBF, 3 HUD flag 0x585000,
|
||||
* 4 var 0x80, 5..8 last played tribe/world/chapter/map (0x585008..14), 9 furthest progress
|
||||
* (0x585018: zeroed on level start, coins make it garbage), 10..21 unused, 22..31 byte table
|
||||
* 0x585024 (strat var 0x93, tribe 1), 32..49 the same table for tribes 2/3 (never used).
|
||||
* flag byte 0x584EFE + c: 0..5 unused, 6..9 language index 0x584F04 (|=2 → wrong texts/crash),
|
||||
* 10..13 option 0x584F08, 14..21 0x584F0C/0x584F10, 22..25 unused, 26..29 option 0x584F18,
|
||||
* 30..49 coin slots of world 0 (never read).
|
||||
* </pre>
|
||||
*
|
||||
* Safe: chapter 1 (Test World) and 32–49.
|
||||
*/
|
||||
public final class TestLevelChapters {
|
||||
private TestLevelChapters() {}
|
||||
|
||||
/** The Test World's chapter. */
|
||||
public static final int TEST_WORLD = 1;
|
||||
|
||||
/** First and last chapter for other mod levels. */
|
||||
public static final int FIRST = 32;
|
||||
|
||||
public static final int LAST = 49;
|
||||
|
||||
public static boolean usable(int chapter) {
|
||||
return chapter == TEST_WORLD || chapter >= FIRST && chapter <= LAST;
|
||||
}
|
||||
|
||||
/** All usable chapters: 1, 32..49. */
|
||||
public static List<Integer> all() {
|
||||
List<Integer> out = new ArrayList<>();
|
||||
out.add(TEST_WORLD);
|
||||
for (int c = FIRST; c <= LAST; c++) {
|
||||
out.add(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable chapter for a stored one: unchanged if usable, else 2..19 → 32..49, 20..31 → 32..43,
|
||||
* anything else → 32 (deterministic, for play-tests and exports of old projects).
|
||||
*/
|
||||
public static int migrate(int chapter) {
|
||||
if (usable(chapter)) {
|
||||
return chapter;
|
||||
}
|
||||
return chapter >= 2 && chapter < FIRST ? FIRST + (chapter - 2) % (LAST - FIRST + 1) : FIRST;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code wanted} if usable and not in {@code used}, else the first free chapter of 32..49; when
|
||||
* all are taken, {@link #migrate}({@code wanted}) (shared: only one of those levels can be on).
|
||||
*/
|
||||
public static int pick(int wanted, Collection<Integer> used) {
|
||||
if (usable(wanted) && !used.contains(wanted)) {
|
||||
return wanted;
|
||||
}
|
||||
for (int c = FIRST; c <= LAST; c++) {
|
||||
if (!used.contains(c)) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return migrate(wanted);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -76,7 +76,10 @@ class PlaytestLifecycleTest {
|
||||
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");
|
||||
assertEquals(
|
||||
"34",
|
||||
t.options().getFirst().defaultValue(),
|
||||
"own chapter (old 4 → 34), not shifted by the saved level");
|
||||
assertFalse(mod("level-mine").playtest());
|
||||
|
||||
cfg.enabledMods = new ArrayList<>(List.of("fog", "playtest-mine"));
|
||||
|
||||
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
@@ -13,8 +14,11 @@ import com.eliasmueller.llamamod.format.stpcmerge.StratCode;
|
||||
import com.eliasmueller.llamamod.format.strat.StratCompiler;
|
||||
import com.eliasmueller.llamamod.format.strat.StratListing;
|
||||
import com.eliasmueller.llamamod.mods.GameData;
|
||||
import com.eliasmueller.llamamod.mods.ModOption;
|
||||
import com.eliasmueller.llamamod.mods.ModOptions;
|
||||
import com.eliasmueller.llamamod.mods.ModRegistry;
|
||||
import com.eliasmueller.llamamod.mods.TestModContext;
|
||||
import com.eliasmueller.llamamod.platform.Config;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -89,11 +93,11 @@ class ArenaTest {
|
||||
void builtLevel() throws IOException {
|
||||
Path cd = GameData.cd();
|
||||
ArenaMod mod = new ArenaMod();
|
||||
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "5"), null);
|
||||
TestModContext ctx = new TestModContext(cd, mod, Map.of("lives", "7", "chapter", "40"), null);
|
||||
mod.apply(ctx);
|
||||
byte[] wad = ctx.written.get("wads/T1L9M005.wad");
|
||||
byte[] wad = ctx.written.get("wads/T1L9M040.wad");
|
||||
assertNotNull(wad, "written: " + ctx.written.keySet());
|
||||
assertNotNull(ctx.written.get("T195.COR"));
|
||||
assertNotNull(ctx.written.get("T1940.COR"));
|
||||
|
||||
Level lv = Level.parse(wad);
|
||||
Map<String, List<MapChunk.Strat>> t = types(lv);
|
||||
@@ -101,6 +105,9 @@ class ArenaTest {
|
||||
assertEquals(1, t.get("ArenaMaster").size());
|
||||
assertEquals(1, t.get("CheckPointPost").size());
|
||||
assertEquals(2, t.get("Coin").size());
|
||||
// tally total: 2 placed + 5 alligators · (6 hit coins + 5 death coins) + 5 exit bonus
|
||||
assertEquals(2 + 5 * (6 + 5) + 5, Bytes.u32(lv.wad().require("LNFO"), 0));
|
||||
assertEquals(0, Bytes.u32(lv.wad().require("LNFO"), 4));
|
||||
assertTrue(
|
||||
!t.containsKey("LevelDoor") && !t.containsKey("ArenaGator"),
|
||||
t.keySet().toString());
|
||||
@@ -140,9 +147,9 @@ class ArenaTest {
|
||||
Path cd = GameData.cd();
|
||||
ArenaMod mod = new ArenaMod();
|
||||
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
|
||||
ctx.write("wads/T1L9M008.wad", new byte[] {1, 2, 3});
|
||||
ctx.write("wads/T1L9M049.wad", new byte[] {1, 2, 3});
|
||||
IOException e = assertThrows(IOException.class, () -> mod.apply(ctx));
|
||||
assertTrue(e.getMessage().contains("chapter 8"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("chapter 49"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -151,9 +158,27 @@ class ArenaTest {
|
||||
ArenaMod mod = new ArenaMod();
|
||||
TestModContext ctx = new TestModContext(cd, mod, Map.of(), null);
|
||||
ArenaMod.apply(ctx, Map.of("probe", "true", "master", "false"));
|
||||
Level lv = Level.parse(ctx.written.get("wads/T1L9M008.wad"));
|
||||
Level lv = Level.parse(ctx.written.get("wads/T1L9M049.wad"));
|
||||
Map<String, List<MapChunk.Strat>> t = types(lv);
|
||||
assertEquals(1, t.get("ArenaProbe").size());
|
||||
assertTrue(!t.containsKey("ArenaMaster"), new ArrayList<>(t.keySet()).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void chapterIsUsableOnly() {
|
||||
ModOption o = new ArenaMod()
|
||||
.options().stream()
|
||||
.filter(x -> x.key().equals("chapter"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals("49", o.defaultValue());
|
||||
assertEquals("32", o.normalize("32"));
|
||||
for (String bad : List.of("1", "8", "9", "31", "50")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> o.normalize(bad), bad);
|
||||
}
|
||||
// a stored chapter from the old range 2..8 falls back to the default
|
||||
Config cfg = new Config();
|
||||
cfg.modOptions.put("arena", Map.of("chapter", "8"));
|
||||
assertEquals("49", ModOptions.resolve(new ArenaMod(), cfg).get("chapter"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import java.util.Map;
|
||||
/**
|
||||
* The arena with level script options for in-game tests (not registered): {@code
|
||||
* APPTEST_CP=build/classes/java/test APPTEST_JAVA_OPTS=-Dllamamod.extraMods=<this class>}, option
|
||||
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot).
|
||||
* {@code script} = {@code key=value;key=value} (probe=true, start=x,z,rot); {@code chapter} 1–49.
|
||||
*/
|
||||
public final class ArenaTestMod implements Mod {
|
||||
@Override
|
||||
@@ -32,7 +32,14 @@ public final class ArenaTestMod implements Mod {
|
||||
|
||||
@Override
|
||||
public List<ModOption> options() {
|
||||
List<ModOption> o = new ArrayList<>(new ArenaMod().options());
|
||||
List<ModOption> o = new ArrayList<>();
|
||||
for (ModOption x : new ArenaMod().options()) {
|
||||
// any chapter, to reproduce the per-chapter memory clashes of unusable ones
|
||||
o.add(
|
||||
x.key().equals("chapter")
|
||||
? ModOption.integer("chapter", x.label(), x.description(), ArenaMod.DEFAULT_CHAPTER, 1, 49)
|
||||
: x);
|
||||
}
|
||||
o.add(ModOption.string("script", "Script options", "key=value;…", ""));
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import com.eliasmueller.llamamod.editor.io.ProjectIO;
|
||||
import com.eliasmueller.llamamod.editor.model.Strat;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
|
||||
import com.eliasmueller.llamamod.editor.model.cmd.History;
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.mods.GameData;
|
||||
import com.eliasmueller.llamamod.mods.Mod;
|
||||
import com.eliasmueller.llamamod.mods.ModOptions;
|
||||
import com.eliasmueller.llamamod.mods.TestModContext;
|
||||
import com.eliasmueller.llamamod.platform.Config;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -38,10 +41,10 @@ class LevelProjectModTest {
|
||||
ProjectIO.save(edit, dir.resolve("village edit.llamalevel"), cd);
|
||||
Project a = Project.create(cd, "new", "t1l1m001");
|
||||
a.scene().settings().prop("name").setRaw(a.scene().settings(), "Level A");
|
||||
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 3);
|
||||
a.scene().settings().prop("targetChapter").setRaw(a.scene().settings(), 40);
|
||||
ProjectIO.save(a, dir.resolve("a.llamalevel"), cd);
|
||||
Project b = Project.create(cd, "new", "t1l1m001");
|
||||
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 3);
|
||||
b.scene().settings().prop("targetChapter").setRaw(b.scene().settings(), 40);
|
||||
ProjectIO.save(b, dir.resolve("b.llamalevel"), cd);
|
||||
Files.writeString(dir.resolve("broken.llamalevel"), "no zip");
|
||||
|
||||
@@ -52,17 +55,20 @@ class LevelProjectModTest {
|
||||
LevelProjectMod ma = (LevelProjectMod) mods.get(0);
|
||||
LevelProjectMod mb = (LevelProjectMod) mods.get(1);
|
||||
LevelProjectMod me = (LevelProjectMod) mods.get(2);
|
||||
assertEquals("3", ma.options().getFirst().defaultValue());
|
||||
assertEquals("2", mb.options().getFirst().defaultValue(), "chapter 3 is taken: next free");
|
||||
assertEquals("40", ma.options().getFirst().defaultValue());
|
||||
assertEquals("32", mb.options().getFirst().defaultValue(), "chapter 40 is taken: first free usable");
|
||||
assertTrue(me.options().isEmpty());
|
||||
assertEquals(Mod.LEVELS, me.category());
|
||||
|
||||
TestModContext ctx = new TestModContext(cdDir, ma, Map.of(), null);
|
||||
ma.apply(ctx);
|
||||
assertTrue(ctx.written.containsKey("wads/T1L9M003.wad"));
|
||||
assertTrue(ctx.written.containsKey("T193.COR"));
|
||||
Level lv = Level.parse(ctx.written.get("wads/T1L9M003.wad"));
|
||||
assertTrue(ctx.written.containsKey("wads/T1L9M040.wad"));
|
||||
assertTrue(ctx.written.containsKey("T1940.COR"));
|
||||
Level lv = Level.parse(ctx.written.get("wads/T1L9M040.wad"));
|
||||
assertEquals(0, lv.map().strats.size());
|
||||
byte[] lnfo = lv.wad().get("LNFO");
|
||||
assertEquals(0, Bytes.u32(lnfo, 0), "tally: no coins (not the base's 55)");
|
||||
assertEquals(0, Bytes.u32(lnfo, 4), "tally: no secrets");
|
||||
|
||||
TestModContext ec = new TestModContext(cdDir, me, Map.of(), null);
|
||||
me.apply(ec);
|
||||
@@ -78,7 +84,7 @@ class LevelProjectModTest {
|
||||
IOException e = assertThrows(IOException.class, () -> me.apply(twice));
|
||||
assertTrue(e.getMessage().contains("already changed"), e.getMessage());
|
||||
TestModContext both = new TestModContext(cdDir, ma, Map.of(), null);
|
||||
both.written.put("wads/T1L9M003.wad", new byte[] {1});
|
||||
both.written.put("wads/T1L9M040.wad", new byte[] {1});
|
||||
assertThrows(IOException.class, () -> ma.apply(both));
|
||||
}
|
||||
|
||||
@@ -100,4 +106,40 @@ class LevelProjectModTest {
|
||||
assertTrue(!v1.equals(v2), "cache key must change with the project file");
|
||||
assertEquals(new LevelProjectSource(dir).mods().getFirst().version(), v2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldChaptersMoveToUsableOnes(@TempDir Path dir) throws IOException {
|
||||
Bases cd = Bases.fromCd(GameData.cd());
|
||||
for (String[] f : new String[][] {{"a", "5"}, {"b", "9"}, {"c", "1"}, {"d", "33"}}) {
|
||||
Project p = Project.create(cd, "new", "t1l1m001");
|
||||
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), Integer.parseInt(f[1]));
|
||||
ProjectIO.save(p, dir.resolve(f[0] + ".llamalevel"), cd);
|
||||
}
|
||||
List<Mod> mods = new LevelProjectSource(dir).mods();
|
||||
assertEquals(
|
||||
List.of("32", "34", "1", "33"),
|
||||
mods.stream().map(m -> m.options().getFirst().defaultValue()).toList(),
|
||||
"2..31 → first free of 32..49; 1 and 33 stay");
|
||||
// a chapter stored in the launcher config by an older version falls back to the default
|
||||
Config cfg = new Config();
|
||||
cfg.modOptions.put("level-a", Map.of("chapter", "5"));
|
||||
assertEquals("32", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
|
||||
cfg.modOptions.put("level-a", Map.of("chapter", "45"));
|
||||
assertEquals("45", ModOptions.resolve(mods.getFirst(), cfg).get("chapter"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moreProjectsThanChaptersShareThem(@TempDir Path dir) throws IOException {
|
||||
Bases cd = Bases.fromCd(GameData.cd());
|
||||
Project p = Project.create(cd, "new", "t1l1m001");
|
||||
p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), 40);
|
||||
for (int i = 0; i < 20; i++) {
|
||||
ProjectIO.save(p, dir.resolve(String.format("p%02d.llamalevel", i)), cd);
|
||||
}
|
||||
List<String> ch = new LevelProjectSource(dir)
|
||||
.mods().stream().map(m -> m.options().getFirst().defaultValue()).toList();
|
||||
assertEquals("40", ch.get(0));
|
||||
assertEquals(18, ch.stream().distinct().count(), ch.toString());
|
||||
assertTrue(ch.stream().allMatch(c -> Integer.parseInt(c) >= 32), ch.toString());
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package com.eliasmueller.llamamod.mods.testworld;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
|
||||
import com.eliasmueller.llamamod.levelbuild.LevelScript;
|
||||
import com.eliasmueller.llamamod.mods.GameData;
|
||||
import com.eliasmueller.llamamod.mods.ModContext;
|
||||
import com.eliasmueller.llamamod.mods.levels.LevelTally;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -135,7 +138,10 @@ class TestWorldGoldenTest {
|
||||
public void progress(double fraction, String message) {}
|
||||
});
|
||||
assertEquals(List.of("T191.COR", "wads/T1L9M001.wad"), List.copyOf(written.keySet()));
|
||||
assertEquals(TEST_WORLD_SHA, sha256(written.get("wads/T1L9M001.wad")));
|
||||
// the mod only recounts the tally (LNFO: 20 coins, no secrets instead of the Village's 55, 2)
|
||||
byte[] wad = written.get("wads/T1L9M001.wad");
|
||||
assertEquals(20, Bytes.u32(Level.parse(wad).wad().require("LNFO"), 0));
|
||||
assertEquals(TEST_WORLD_SHA, sha256(LevelTally.withTotals(wad, 55, 2)));
|
||||
assertEquals(
|
||||
"c55e3babf4855540f14daa60b979a2534e3732037854d9ad785f69f370d095d0", sha256(written.get("T191.COR")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eliasmueller.llamamod.mods.testworld;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.eliasmueller.llamamod.format.Bytes;
|
||||
import com.eliasmueller.llamamod.format.Level;
|
||||
import com.eliasmueller.llamamod.format.MapChunk;
|
||||
import com.eliasmueller.llamamod.format.Strats;
|
||||
import com.eliasmueller.llamamod.mods.GameData;
|
||||
import com.eliasmueller.llamamod.mods.TestModContext;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The tally of the Test World counts its own coins, not the Village base's 55. */
|
||||
class TestWorldTallyTest {
|
||||
@Test
|
||||
void lnfoCountsPlacedCoins() throws IOException {
|
||||
TestWorldMod mod = new TestWorldMod();
|
||||
TestModContext ctx = new TestModContext(GameData.cd(), mod, Map.of(), null);
|
||||
mod.apply(ctx);
|
||||
Level lv = Level.parse(ctx.written.get(TestWorldMod.OUT_WAD));
|
||||
Map<String, List<MapChunk.Strat>> t = Strats.levelTypes(lv.wad().require("STPC"), lv.wfpc(), lv.map());
|
||||
int coins = t.getOrDefault("Coin", List.of()).size()
|
||||
+ 5 * t.getOrDefault("Coin5", List.of()).size();
|
||||
byte[] lnfo = lv.wad().require("LNFO");
|
||||
System.out.println(
|
||||
"TALLY test world " + Bytes.u32(lnfo, 0) + "/" + Bytes.u32(lnfo, 4) + " types " + t.keySet());
|
||||
assertTrue(coins > 0);
|
||||
assertEquals(coins, Bytes.u32(lnfo, 0));
|
||||
assertEquals(t.getOrDefault("Secret", List.of()).size(), Bytes.u32(lnfo, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.eliasmueller.llamamod.save;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TestLevelChaptersTest {
|
||||
/** Coin slot 0x584F1C + 4·(54 + c) and flag byte 0x584EC8 + 54 + c of world 9 (see the class comment). */
|
||||
@Test
|
||||
void usableChaptersAvoidTheGlobalsBehindTheArrays() {
|
||||
for (int c = 0; c <= 50; c++) {
|
||||
int coin = 0x584F1C + 4 * (9 * 6 + c);
|
||||
int flag = 0x584EC8 + 9 * 6 + c;
|
||||
boolean coinFree = c == 1
|
||||
|| coin >= 0x58501C && coin < 0x585024
|
||||
|| coin >= 0x585028 && coin < 0x58504C
|
||||
|| coin >= 0x585074 && coin <= 0x5850BB;
|
||||
boolean flagFree = flag < 0x584F04 || flag >= 0x584F14 && flag < 0x584F18 || flag >= 0x584F1C;
|
||||
assertEquals(coinFree && flagFree && c >= 1 && c <= 49, TestLevelChapters.usable(c), "chapter " + c);
|
||||
}
|
||||
assertEquals(19, TestLevelChapters.all().size());
|
||||
assertEquals(0x585018, 0x584F1C + 4 * (9 * 6 + 9), "chapter 9 = furthest progress");
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrate() {
|
||||
assertEquals(1, TestLevelChapters.migrate(1));
|
||||
assertEquals(40, TestLevelChapters.migrate(40));
|
||||
assertEquals(32, TestLevelChapters.migrate(2));
|
||||
assertEquals(38, TestLevelChapters.migrate(8));
|
||||
assertEquals(49, TestLevelChapters.migrate(19));
|
||||
assertEquals(32, TestLevelChapters.migrate(20));
|
||||
assertEquals(43, TestLevelChapters.migrate(31));
|
||||
assertEquals(32, TestLevelChapters.migrate(0));
|
||||
assertEquals(32, TestLevelChapters.migrate(60));
|
||||
for (int c = -1; c < 60; c++) {
|
||||
assertTrue(TestLevelChapters.usable(TestLevelChapters.migrate(c)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pick() {
|
||||
assertEquals(40, TestLevelChapters.pick(40, Set.of()));
|
||||
assertEquals(1, TestLevelChapters.pick(1, Set.of(32)));
|
||||
assertEquals(33, TestLevelChapters.pick(40, Set.of(32, 40)));
|
||||
assertEquals(32, TestLevelChapters.pick(5, Set.of()));
|
||||
Set<Integer> full = new HashSet<>(TestLevelChapters.all());
|
||||
assertEquals(38, TestLevelChapters.pick(8, full), "all taken: shared");
|
||||
assertEquals(45, TestLevelChapters.pick(45, full));
|
||||
assertFalse(List.of(1).contains(TestLevelChapters.pick(1, Set.of(1))));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user