diff --git a/notes/geometry.md b/notes/geometry.md index 43d084e..8e73b4a 100644 --- a/notes/geometry.md +++ b/notes/geometry.md @@ -210,7 +210,10 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the `geom_export`/`geom_import` = the old raw export space (z < 0). * `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png - (textured software renderer). + (textured software renderer, right-handed camera, always in game space – the same way round as in the + game, checked against an in-game shot of t1l1m001: `img/geom_preview_handedness.jpg`; before 2026-11-07 + the previews rendered export space and were mirrored). `tools/vorschau/render_level.py` (Blender) reads + file space and is not mirrored either (checked on the test world, 2026-11-07). * `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]` * `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads. * `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision). diff --git a/notes/img/app/saves-640.jpg b/notes/img/app/saves-640.jpg new file mode 100644 index 0000000..486e255 Binary files /dev/null and b/notes/img/app/saves-640.jpg differ diff --git a/notes/img/app/wizard-copy-mb-fixed.jpg b/notes/img/app/wizard-copy-mb-fixed.jpg new file mode 100644 index 0000000..0bddc19 Binary files /dev/null and b/notes/img/app/wizard-copy-mb-fixed.jpg differ diff --git a/notes/img/geom_preview_handedness.jpg b/notes/img/geom_preview_handedness.jpg new file mode 100644 index 0000000..4da5403 Binary files /dev/null and b/notes/img/geom_preview_handedness.jpg differ diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index 25ee282..1d96ad8 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -76,8 +76,14 @@ Config()` that never touched a file overwrites everything (installer, tests). `save` returns true when the merge pulled in values written elsewhere; `Config.refresh(file)` does the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes when its window is activated and after a save that pulled something in, then rebuilds the Mods and -Settings tabs (status "Took over changes made in another LlamaMod window."), so a stale window's -next click cannot undo another window's selection. +Settings tabs (status "Took over changes made in another LlamaMod window.", transient: after 6 s the +previous status comes back, `ui/StatusLine.flash`), so a stale window's next click cannot undo another +window's selection. The Mods tab rebuild keeps keyboard focus, caret and selection of the option the user +is typing in (`ui/FocusKeeper`: inputs are tagged `modId/optionKey`; FlatLaf's select-all on focus gain is +suppressed for the restored field, it would eat the next key). +Short windows: header 150 px from 860 px window height, 104 px below, 92 px below 700 px. The Saves tab +shows the table legend ("Coins: …") in the left column, so at the 820×640 minimum the chapter table +still shows 4 rows (7 at 720 px; `img/app/saves-640.jpg`). Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left window after a click into it), `img/app/interrupted-apply-notice.jpg`, `img/app/saves-min-width.jpg`, `img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the @@ -92,6 +98,9 @@ looking for `…/Hd/groove.exe` next to `…/Cd/wads/`. After copying, `platform same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected. The ISO also contains three `(null)king*.ksv` leftovers; they are not copied. +Wizard progress: the bar follows the copied bytes exactly (`Installer.COPY_SHARE` of the install progress +is the copy), so "Copying 553 of 568 MB" stands next to 97 %; the short steps after it (check, game folder, +re-applying mods) show a busy bar without a percentage (`img/app/wizard-copy-mb-fixed.jpg`). ## Registry, CD path and the exe patch @@ -356,14 +365,14 @@ and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on f T=tools/apptest.py $T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \ --seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep] - # setup + apply + unlock slot 3 at world/chapter (cursor) + start + tolevel + keys + stop + # setup + apply + unlock slots 2 and 3 at world/chapter (cursor) + start + tolevel (slot 2) + keys + stop # exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg, # zz_failure.jpg, game.log, apply.log $T setup NAME # install session (fast after the template exists) $T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original $T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start) $T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio -$T tolevel NAME --world 1 # menu "Spiel starten" -> slot 3 -> map -> Enter -> skip fly-by +$T tolevel NAME --world 1 [--slot 3] # menu "Spiel starten" -> slot (default 3) -> map -> Enter -> skip fly-by $T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s $T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG $T ocr NAME [WxH+X+Y] # German tesseract OCR @@ -393,5 +402,24 @@ s.key("Up", 1.5); s.shot("a.png"); print(s.crashed()); s.stop()`. Controls are camera-relative (see testharness.md for movement measurements). * Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start. `tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture. +* Flakiness under load: with several agents' games running (load average 30 on 6 cores) the game + draws only a few frames per second and the slot screen ignores presses shorter than a frame + (seen 2026-11-07 at load ~30: the save slot carousel ignored Left/Right – 0.3 to 4 s holds – for + minutes while Return/Escape worked; root cause not found). `run` therefore loads **slot 2**, where + the carousel opens (no turning; slot 3 gets the same unlock for mods whose save-slot option is 3); + 3 + 3 runs passed after that change. `tolevel` checks every menu step and repeats it with longer + holds (Return on menus 0.6 s first – 0.2 s presses were often missed under load, reported by the + test-world agent too): Return on the main menu until the slot screen shows (0.8/1.0/1.2 s), Right one step at a + time until OCR reads the wanted slot (the carousel wraps 3 → 1; a step that did not move is + repeated with 0.6/1.0/1.5 s; gives up after 240 s), Return on the slot until the slot screen is + gone, Return on the map again if the picture did not change after 8 s. Before opening the slot it + checks the slot text: an empty slot (would start a new game in the village) or another world's + name (`SLOT_WORLD_NAMES`, verified: 1 "dorf", 9 "testlevel") is an error instead of a wrong level. + `run` retries the start once when the game crashed before the main menu (Wine page fault at + start-up) or the slot screen never came: report.json `"retries": [{attempt, phase, error}]`, + `retry1_failure.jpg`/`retry1_game.log` in the out dir, result line "OK (after a retry: …)"; + `--no-retry` turns it off. report.json also records `load_avg`/`cpus`. Your own key sequences + (`--seq`) are not retried: hold keys ≥0.5 s for menus when the machine is busy. + Offline tests of the retry/slot logic: `python3 -m unittest tools/test_apptest.py`. * Session state (PIDs, display): `//harness/state.json`; logs `xvfb.log`, `pulse.log` there, game output in `data/logs/game.log`. Delete `/` to start from scratch. diff --git a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java index ed51fa7..75f45dc 100644 --- a/src/main/java/com/eliasmueller/llamamod/platform/Installer.java +++ b/src/main/java/com/eliasmueller/llamamod/platform/Installer.java @@ -53,7 +53,7 @@ public final class Installer { List cd = src.cdFiles(); long total = totalSize(hd) + totalSize(cd); long[] done = {0}; - Progress copyP = p.sub(0, 0.85); + Progress copyP = p.sub(0, COPY_SHARE); // files already copied are only trusted when an interrupted install of the same source // resumes; a new setup (other or repaired image) copies everything again Path marker = paths.root().resolve("original").resolve(".install-source"); @@ -152,6 +152,9 @@ public final class Installer { } } + /** Share of {@link #install}'s progress that is the file copy (byte-proportional); the rest is quick steps. */ + public static final double COPY_SHARE = 0.85; + /** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */ static String copyMessage(long done, long total, String file) { return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file); diff --git a/src/main/java/com/eliasmueller/llamamod/ui/FocusKeeper.java b/src/main/java/com/eliasmueller/llamamod/ui/FocusKeeper.java new file mode 100644 index 0000000..c36ee02 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/ui/FocusKeeper.java @@ -0,0 +1,98 @@ +package com.eliasmueller.llamamod.ui; + +import java.awt.Component; +import java.awt.Container; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import javax.swing.JComponent; +import javax.swing.JSpinner; +import javax.swing.SwingUtilities; +import javax.swing.text.JTextComponent; + +/** + * Keeps keyboard focus (and caret/selection) across a rebuild that replaces components: inputs are tagged with a + * stable key, the focused one is remembered before the rebuild and its replacement focused afterwards. + */ +final class FocusKeeper { + private static final String KEY = "llamamod.focusKey"; + private static final String SELECT_ALL_POLICY = "JTextField.selectAllOnFocusPolicy"; + + /** What had the focus: the tag of the input and, for text, caret and selection. */ + record Snapshot(String key, int caret, int selStart, int selEnd) {} + + private FocusKeeper() {} + + static T tag(T c, String key) { + c.putClientProperty(KEY, key); + return c; + } + + /** The tagged input that contains {@code focusOwner} (e.g. a spinner's text field), or null. */ + static Snapshot capture(Component focusOwner, Container root) { + for (Component c = focusOwner; c != null && c != root; c = c.getParent()) { + if (c instanceof JComponent j && j.getClientProperty(KEY) instanceof String key) { + if (focusOwner instanceof JTextComponent t) { + return new Snapshot(key, t.getCaretPosition(), t.getSelectionStart(), t.getSelectionEnd()); + } + return new Snapshot(key, -1, -1, -1); + } + } + return null; + } + + /** Finds the replacement of the remembered input under {@code root}, restores caret/selection; null if gone. */ + static Component restore(Snapshot s, Container root) { + if (s == null) { + return null; + } + JComponent c = find(root, s.key()); + if (c == null) { + return null; + } + Component target = + c instanceof JSpinner sp && sp.getEditor() instanceof JSpinner.DefaultEditor ed ? ed.getTextField() : c; + if (target instanceof JTextComponent t && s.caret() >= 0) { + int len = t.getDocument().getLength(); + int a = Math.min(s.selStart(), len); + int b = Math.min(s.selEnd(), len); + if (a != b) { + // keep the direction: the caret is where the user left it + t.setCaretPosition(s.caret() == a ? b : a); + t.moveCaretPosition(Math.min(s.caret(), len)); + } else { + t.setCaretPosition(Math.min(s.caret(), len)); + } + } + return target; + } + + /** Focuses the restored input without FlatLaf's select-all on focus gain (it would eat the next key). */ + static void focus(Component c) { + if (c instanceof JTextComponent t) { + t.putClientProperty(SELECT_ALL_POLICY, "never"); + t.addFocusListener(new FocusAdapter() { + @Override + public void focusGained(FocusEvent e) { + t.removeFocusListener(this); + SwingUtilities.invokeLater(() -> t.putClientProperty(SELECT_ALL_POLICY, null)); + } + }); + } + c.requestFocusInWindow(); + } + + private static JComponent find(Container root, String key) { + for (Component c : root.getComponents()) { + if (c instanceof JComponent j && key.equals(j.getClientProperty(KEY))) { + return j; + } + if (c instanceof Container sub) { + JComponent f = find(sub, key); + if (f != null) { + return f; + } + } + } + return null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java index 8ff2f66..74bcfde 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/MainFrame.java @@ -48,6 +48,7 @@ final class MainFrame extends JFrame { private final HeaderPanel header = new HeaderPanel(); private final JButton playButton = new JButton(Strings.t("play")); private final JLabel statusLabel = new JLabel(" "); + private final StatusLine statusLine = new StatusLine(statusLabel, StatusLine.TRANSIENT_MS); private final JProgressBar progress = new JProgressBar(0, 1000); private final JButton cancelButton = new JButton(Strings.t("cancel")); private final JTabbedPane tabs = new JTabbedPane(); @@ -124,15 +125,19 @@ final class MainFrame extends JFrame { return l; } - /** Short windows (laptops, 720p) get a lower header so the mod list keeps its room. */ + /** Short windows (laptops, 720p) get a lower header so the mod list and the save table keep their room. */ private void fitHeader() { - int h = getHeight() < 860 ? 104 : 150; + int h = headerHeight(getHeight()); if (header.getPreferredSize().height != h) { header.setPreferredSize(new Dimension(900, h)); header.revalidate(); } } + static int headerHeight(int windowHeight) { + return windowHeight < 700 ? 92 : windowHeight < 860 ? 104 : 150; + } + private void buildUi() { JPanel root = new JPanel(new BorderLayout()); header.setLayout(new BorderLayout()); @@ -284,7 +289,7 @@ final class MainFrame extends JFrame { } modsPanel.reload(); settingsPanel.reload(); - setStatus(Strings.t("status.configMerged")); + setTransientStatus(Strings.t("status.configMerged")); } void onBusy(Consumer l) { @@ -300,7 +305,11 @@ final class MainFrame extends JFrame { } void setStatus(String s) { - statusLabel.setText(s); + statusLine.set(s); + } + + void setTransientStatus(String s) { + statusLine.flash(s); } private void updateGameRunning() { diff --git a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java index 7c29c26..657c7f7 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java @@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.mods.levels.LevelProjects; import com.eliasmueller.llamamod.util.SwingListeners; import java.awt.BorderLayout; import java.awt.Color; +import java.awt.Component; import java.awt.Cursor; import java.awt.Dimension; import java.awt.FlowLayout; @@ -17,6 +18,7 @@ import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.GridLayout; import java.awt.Insets; +import java.awt.KeyboardFocusManager; import java.awt.Point; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; @@ -116,6 +118,9 @@ final class ModsPanel extends JPanel { // rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition(); firstReload = false; + // e.g. a config merge while the user types in an option: the new field gets the focus and caret + FocusKeeper.Snapshot focus = FocusKeeper.capture( + KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(), list); list.removeAll(); boxes.clear(); removeButtons.clear(); @@ -148,6 +153,10 @@ final class ModsPanel extends JPanel { updateState(); list.revalidate(); list.repaint(); + Component refocus = FocusKeeper.restore(focus, list); + if (refocus != null) { + FocusKeeper.focus(refocus); + } SwingUtilities.invokeLater(() -> { var viewport = scroll.getViewport(); viewport.validate(); @@ -248,7 +257,7 @@ final class ModsPanel extends JPanel { outer.add(c, BorderLayout.CENTER); JCheckBox cb = new JCheckBox(); cb.setSelected(frame.state.cfg.enabledMods.contains(m.id())); - boxes.put(m.id(), cb); + boxes.put(m.id(), FocusKeeper.tag(cb, m.id())); GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST); g.gridheight = 2; g.insets = new Insets(0, 0, 0, 10); @@ -451,7 +460,7 @@ final class ModsPanel extends JPanel { int row = 0; for (ModOption o : m.options()) { String v = values.get(o.key()); - JComponent input = optionInput(m, o, v); + JComponent input = optionInput(m, o, v, m.id() + "/" + o.key()); String tip = tip(o); GridBagConstraints g = new GridBagConstraints(); g.gridy = row; @@ -513,12 +522,12 @@ final class ModsPanel extends JPanel { return t; } - private JComponent optionInput(Mod m, ModOption o, String value) { + private JComponent optionInput(Mod m, ModOption o, String value, String key) { switch (o.type()) { case BOOL -> { JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value)); b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b)); - optionInputs.add(b); + optionInputs.add(FocusKeeper.tag(b, key)); return b; } case INT -> { @@ -527,7 +536,7 @@ final class ModsPanel extends JPanel { Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1))); s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s)); s.setPreferredSize(new Dimension(110, s.getPreferredSize().height)); - optionInputs.add(s); + optionInputs.add(FocusKeeper.tag(s, key)); return s; } case CHOICE -> { @@ -540,7 +549,7 @@ final class ModsPanel extends JPanel { } c.addActionListener(e -> setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c)); - optionInputs.add(c); + optionInputs.add(FocusKeeper.tag(c, key)); return c; } case MULTI -> { @@ -563,16 +572,16 @@ final class ModsPanel extends JPanel { } setOption(m, o, o.multiValue(chosen), p); }); - optionInputs.add(b); + optionInputs.add(FocusKeeper.tag(b, key + "#" + c.value())); p.add(b); } return p; } case SEED -> { - JTextField t = textInput(m, o, value, 12); + JTextField t = textInput(m, o, value, 12, key); JButton random = new JButton(Strings.t("mods.option.random")); random.addActionListener(e -> t.setText(ModOptions.randomSeed())); - optionInputs.add(random); + optionInputs.add(FocusKeeper.tag(random, key + "#random")); JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); p.setOpaque(false); p.add(t); @@ -581,15 +590,15 @@ final class ModsPanel extends JPanel { return p; } default -> { - return textInput(m, o, value, 20); + return textInput(m, o, value, 20, key); } } } - private JTextField textInput(Mod m, ModOption o, String value, int columns) { + private JTextField textInput(Mod m, ModOption o, String value, int columns, String key) { JTextField t = new JTextField(value, columns); SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t)); - optionInputs.add(t); + optionInputs.add(FocusKeeper.tag(t, key)); return t; } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java index ba3faf6..f67fcdb 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SavesPanel.java @@ -78,9 +78,14 @@ final class SavesPanel extends JPanel { JPanel left = new JPanel(new BorderLayout()); JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f); hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0)); + // the table's legend lives here, where there is room: at the minimum window height every + // line under the table would cost a table row + JLabel coinsHint = App.hintLabel(Strings.t("saves.coinsHint"), 200, 11.5f); + coinsHint.setBorder(BorderFactory.createEmptyBorder(10, 2, 0, 0)); JPanel leftTop = new JPanel(new BorderLayout()); leftTop.add(slots, BorderLayout.NORTH); leftTop.add(hint, BorderLayout.CENTER); + leftTop.add(coinsHint, BorderLayout.SOUTH); left.add(leftTop, BorderLayout.NORTH); add(left, BorderLayout.WEST); @@ -94,7 +99,7 @@ final class SavesPanel extends JPanel { slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f)); ed.add(slotTitle, g); g.gridy++; - g.insets = new Insets(2, 0, 10, 0); + g.insets = new Insets(2, 0, 4, 0); ed.add(slotInfo, g); for (int w = 1; w <= Worlds.COUNT; w++) { @@ -143,10 +148,6 @@ final class SavesPanel extends JPanel { JScrollPane sp = new JScrollPane(table); sp.setPreferredSize(new Dimension(420, 180)); ed.add(sp, g); - g.gridy++; - g.weighty = 0; - g.fill = GridBagConstraints.HORIZONTAL; - ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g); add(ed, BorderLayout.CENTER); JPanel buttons = new JPanel(new BorderLayout()); diff --git a/src/main/java/com/eliasmueller/llamamod/ui/SetupWizard.java b/src/main/java/com/eliasmueller/llamamod/ui/SetupWizard.java index b6f1351..a03459a 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/SetupWizard.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/SetupWizard.java @@ -313,6 +313,24 @@ final class SetupWizard extends JDialog { dispose(); } + /** + * The bar follows the copied megabytes exactly (so it matches the "Copying x of y MB" text); the short steps + * after the copy show a busy bar instead of a percentage. + */ + private void showProgress(double installFraction, String message) { + double copy = barFraction(installFraction); + boolean copying = copy < 1; + bar.setIndeterminate(!copying); + bar.setStringPainted(copying); + bar.setValue((int) Math.round(copy * 1000)); + progressText.setText(message); + } + + /** Copy share of the installer progress mapped onto 0..1. */ + static double barFraction(double installFraction) { + return Math.max(0, Math.min(1, installFraction / Installer.COPY_SHARE)); + } + private void startInstall() { if (!checkSource()) { frame.error(new IllegalArgumentException(Strings.t("wizard.noSource"))); @@ -331,10 +349,7 @@ final class SetupWizard extends JDialog { Progress p = new Progress() { @Override public void update(double f, String m) { - SwingUtilities.invokeLater(() -> { - bar.setValue((int) (f * 1000)); - progressText.setText(m); - }); + SwingUtilities.invokeLater(() -> showProgress(f, m)); } @Override @@ -346,10 +361,10 @@ final class SetupWizard extends JDialog { @Override protected Object doInBackground() throws Exception { try (GameSource gs = GameSource.open(src)) { - new Installer(state.paths).install(gs, state.cfg, p.sub(0, 0.95)); + new Installer(state.paths).install(gs, state.cfg, p); } // re-setup keeps the active mods - state.modManager.apply(state.activeMods(), p.sub(0.95, 1)); + state.modManager.apply(state.activeMods(), (f, m) -> p.update(1, m)); return state.saves.migrateNullSaves(); } diff --git a/src/main/java/com/eliasmueller/llamamod/ui/StatusLine.java b/src/main/java/com/eliasmueller/llamamod/ui/StatusLine.java new file mode 100644 index 0000000..8b4dd63 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/ui/StatusLine.java @@ -0,0 +1,36 @@ +package com.eliasmueller.llamamod.ui; + +import javax.swing.JLabel; +import javax.swing.Timer; + +/** The status bar text: lasting messages, and transient ones that give way to the lasting one after a while. */ +final class StatusLine { + static final int TRANSIENT_MS = 6000; + + private final JLabel label; + private final Timer expire; + private String resting; + + StatusLine(JLabel label, int transientMs) { + this.label = label; + this.resting = label.getText(); + expire = new Timer(transientMs, e -> label.setText(resting)); + expire.setRepeats(false); + } + + void set(String s) { + expire.stop(); + resting = s; + label.setText(s); + } + + /** Shown for a few seconds, then the last lasting message comes back (unless something newer replaced it). */ + void flash(String s) { + label.setText(s); + expire.restart(); + } + + boolean showingTransient() { + return expire.isRunning(); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/ui/FocusKeeperTest.java b/src/test/java/com/eliasmueller/llamamod/ui/FocusKeeperTest.java new file mode 100644 index 0000000..db5fadc --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/ui/FocusKeeperTest.java @@ -0,0 +1,83 @@ +package com.eliasmueller.llamamod.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.JTextField; +import javax.swing.SpinnerNumberModel; +import org.junit.jupiter.api.Test; + +class FocusKeeperTest { + private static JPanel form(String text) { + JPanel root = new JPanel(); + JPanel card = new JPanel(); + card.add(FocusKeeper.tag(new JTextField(text, 10), "mod/name")); + card.add(FocusKeeper.tag(new JSpinner(new SpinnerNumberModel(5, 0, 9, 1)), "mod/count")); + root.add(card); + return root; + } + + private static JTextField field(JPanel root) { + return (JTextField) ((JPanel) root.getComponent(0)).getComponent(0); + } + + @Test + void caretMovesToTheRebuiltField() { + JPanel before = form("llama"); + JTextField old = field(before); + old.setCaretPosition(3); + FocusKeeper.Snapshot s = FocusKeeper.capture(old, before); + assertNotNull(s); + assertEquals("mod/name", s.key()); + + JPanel after = form("llama"); + assertSame(field(after), FocusKeeper.restore(s, after)); + assertEquals(3, field(after).getCaretPosition()); + } + + @Test + void selectionAndShorterTextAreHandled() { + JPanel before = form("llamas"); + JTextField old = field(before); + old.setCaretPosition(1); + old.moveCaretPosition(5); + FocusKeeper.Snapshot s = FocusKeeper.capture(old, before); + + JPanel after = form("llamas"); + FocusKeeper.restore(s, after); + assertEquals(1, field(after).getSelectionStart()); + assertEquals(5, field(after).getSelectionEnd()); + assertEquals(5, field(after).getCaretPosition()); + + JPanel shorter = form("ll"); + FocusKeeper.restore(s, shorter); + assertEquals(2, field(shorter).getCaretPosition()); + } + + @Test + void spinnerEditorMapsToTheSpinner() { + JPanel before = form("x"); + JSpinner sp = (JSpinner) ((JPanel) before.getComponent(0)).getComponent(1); + JTextField editor = ((JSpinner.DefaultEditor) sp.getEditor()).getTextField(); + FocusKeeper.Snapshot s = FocusKeeper.capture(editor, before); + assertEquals("mod/count", s.key()); + + JPanel after = form("x"); + JSpinner sp2 = (JSpinner) ((JPanel) after.getComponent(0)).getComponent(1); + assertSame(((JSpinner.DefaultEditor) sp2.getEditor()).getTextField(), FocusKeeper.restore(s, after)); + } + + @Test + void untaggedOrMissingGivesNull() { + JPanel root = new JPanel(); + JTextField plain = new JTextField(); + root.add(plain); + assertNull(FocusKeeper.capture(plain, root)); + assertNull(FocusKeeper.capture(null, root)); + assertNull(FocusKeeper.restore(new FocusKeeper.Snapshot("gone", 0, 0, 0), root)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/ui/MainFrameLayoutTest.java b/src/test/java/com/eliasmueller/llamamod/ui/MainFrameLayoutTest.java new file mode 100644 index 0000000..7a7ee01 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/ui/MainFrameLayoutTest.java @@ -0,0 +1,16 @@ +package com.eliasmueller.llamamod.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class MainFrameLayoutTest { + @Test + void headerShrinksForShortWindows() { + assertEquals(150, MainFrame.headerHeight(900)); + assertEquals(104, MainFrame.headerHeight(760)); + assertEquals(104, MainFrame.headerHeight(720)); + // the 640 px minimum: the Saves table keeps four rows + assertEquals(92, MainFrame.headerHeight(640)); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/ui/SetupWizardProgressTest.java b/src/test/java/com/eliasmueller/llamamod/ui/SetupWizardProgressTest.java new file mode 100644 index 0000000..2acac9a --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/ui/SetupWizardProgressTest.java @@ -0,0 +1,18 @@ +package com.eliasmueller.llamamod.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.eliasmueller.llamamod.platform.Installer; +import org.junit.jupiter.api.Test; + +class SetupWizardProgressTest { + @Test + void barFollowsTheCopiedMegabytes() { + // 553 of 568 MB copied: the bar must say 97 %, not the 78 % of the whole setup + double copied = 553.0 / 568.0; + assertEquals(copied, SetupWizard.barFraction(copied * Installer.COPY_SHARE), 1e-9); + assertEquals(0, SetupWizard.barFraction(0)); + assertEquals(1, SetupWizard.barFraction(Installer.COPY_SHARE)); + assertEquals(1, SetupWizard.barFraction(0.9)); // checking/creating: full (shown as busy) + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/ui/StatusLineTest.java b/src/test/java/com/eliasmueller/llamamod/ui/StatusLineTest.java new file mode 100644 index 0000000..faced30 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/ui/StatusLineTest.java @@ -0,0 +1,45 @@ +package com.eliasmueller.llamamod.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.swing.JLabel; +import javax.swing.SwingUtilities; +import org.junit.jupiter.api.Test; + +class StatusLineTest { + private static void onEdt(Runnable r) throws Exception { + SwingUtilities.invokeAndWait(r); + } + + @Test + void transientMessageGivesWayToTheLastingOne() throws Exception { + JLabel l = new JLabel(" "); + StatusLine s = new StatusLine(l, 150); + onEdt(() -> { + s.set("Ready."); + s.flash("Took over changes made in another LlamaMod window."); + }); + assertEquals("Took over changes made in another LlamaMod window.", l.getText()); + assertTrue(s.showingTransient()); + Thread.sleep(600); + onEdt(() -> {}); + assertEquals("Ready.", l.getText()); + assertFalse(s.showingTransient()); + } + + @Test + void newerLastingMessageIsNotOverwritten() throws Exception { + JLabel l = new JLabel(" "); + StatusLine s = new StatusLine(l, 150); + onEdt(() -> { + s.set("Ready."); + s.flash("merged"); + s.set("Applying mods"); + }); + Thread.sleep(600); + onEdt(() -> {}); + assertEquals("Applying mods", l.getText()); + } +} diff --git a/tools/apptest.py b/tools/apptest.py index 2a76c10..b86a811 100755 --- a/tools/apptest.py +++ b/tools/apptest.py @@ -14,14 +14,17 @@ Commands (NAME first; --display only for 'start'/'run', remembered afterwards): apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch) - apptest.py tolevel NAME [--world W] main menu -> slot 3 -> map -> level -> gameplay + apptest.py tolevel NAME [--world W] [--slot S] main menu -> slot S (3) -> map -> level -> gameplay apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2) apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG) apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop) apptest.py status NAME game/window/crash/audio state (exit 3 = crashed) apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep] - all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage + [--no-retry] all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage. + A crash before the main menu or a missed save slot screen + is retried once (report.json "retries", out/retry1_*); + --no-retry turns that off Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar), --iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`. @@ -34,6 +37,7 @@ Safety: never uses display :0, never touches ~/.wine-lama, kills only PIDs it st import fcntl import json import os +import re import shutil import signal import subprocess @@ -51,6 +55,10 @@ SLOT_CROP = "1600x280+160+740" # save-slot text "Gespeichertes Spiel N / MENU_CROP = "700x300+1150+650" # main menu items (bottom right) +# world -> word the save slot shows for it (OCR, lower case); only the verified ones +SLOT_WORLD_NAMES = {1: "dorf", 9: "testlevel"} + + class Crash(Exception): pass @@ -517,8 +525,8 @@ class Session: raise Timeout("timeout (%d s) waiting for %s" % (timeout, what)) # ------------------------------------------------------------ navigation - def tolevel(self, world=None, skip_flyby=True): - """Main menu -> "Spiel starten" -> slot 3 (rightmost) -> map (cursor from --unlock ... --cursor) + def tolevel(self, world=None, skip_flyby=True, on_menu=None, slot=3): + """Main menu -> "Spiel starten" -> save slot (default 3) -> map (cursor from --unlock ... --cursor) -> Enter -> skip the fly-by -> gameplay. Returns timings.""" T = {} t0 = tx = time.time() @@ -528,10 +536,14 @@ class Session: raise Crash(c) if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")): log("main menu") + if on_menu: + on_menu() time.sleep(0.5) - self.key("Return") + self.key("Return", 0.6) break if fuzzy_in("gespeichertes", self.slot_text()): + if on_menu: + on_menu() break if time.time() - tx > 40: log("no menu yet -> Return (skips logo/demo)") @@ -541,21 +553,15 @@ class Session: raise Timeout("main menu not reached") time.sleep(1) T["menu"] = round(time.time() - t0, 1) - self.wait_for(lambda: fuzzy_in("gespeichertes", self.slot_text()), 20, "save slot screen") - time.sleep(0.4) - for _ in range(2): # 3-slot slider without wrap: slot 3 is the rightmost - self.key("Right", 0.3) - time.sleep(0.3) - for i in range(3): - time.sleep(0.6) - t = self.slot_text() - if fuzzy_in("kapitel", t) and not fuzzy_in("leer", t): - log("slot: %s" % t.split(" | ")[0]) - break - self.key("Right", 0.3) - else: - log("WARNING: slot 3 text not recognised (%s)" % t) - self.key("Return") + self._to_slot_screen() + t = self._select_slot(slot) + if not self._slot_ok(t): + # an empty slot starts a new game in the village: never the level that was asked for + raise Timeout("save slot %d is empty (%s) - unlock it first" % (slot, t)) + name = SLOT_WORLD_NAMES.get(world) + if name and not fuzzy_in(name, t): + raise Timeout("save slot %d holds another world (%s), expected %r" % (slot, t, name)) + self._open_slot() T["slot"] = round(time.time() - t0, 1) if world == 9: self.wait_for(lambda: self.classify() == "map", 30, "world 9 map") @@ -563,10 +569,11 @@ class Session: else: self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map") self.wait_stable(still=1.5, timeout=20) - self.key("Return") + map_thumb = self.thumb() + self.key("Return", 0.6) t_load = time.time() T["map"] = round(t_load - t0, 1) - skipped, good, states = False, 0, [] + skipped, good, states, again = False, 0, [], 0 while time.time() - t_load < 150: c = self.crashed() if c: @@ -575,7 +582,12 @@ class Session: if not states or states[-1] != st: states.append(st) log("state: %s (%.1f s after level start)" % (st, time.time() - t_load)) - if st == "letterbox" and skip_flyby and not skipped: + if "black" not in states and "letterbox" not in states and time.time() - t_load > 8 + 8 * again \ + and again < 2 and self.diff(self.thumb(), map_thumb) < 6: + again += 1 # still on the map: the Return was missed + log("level did not start -> Return again") + self.key("Return", 0.6 + 0.3 * again) + elif st == "letterbox" and skip_flyby and not skipped: time.sleep(0.5) self.key("Return") skipped = True @@ -596,6 +608,79 @@ class Session: log("gameplay after %.1f s, states %s" % (T["gameplay"], states)) return T + def _to_slot_screen(self, timeout=45): + """Waits for the save slot screen; a Return the menu missed (still fading in, or a key press shorter + than one frame on a loaded machine) is repeated, held a little longer each time.""" + t0 = tr = time.time() + holds = [0.8, 1.0, 1.2] + while time.time() - t0 < timeout: + c = self.crashed() + if c: + raise Crash(c) + if fuzzy_in("gespeichertes", self.slot_text()): + return + if time.time() - tr > 5 and fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, + scale="100%")): + hold = holds.pop(0) if holds else 1.2 + log("still in the main menu -> Return again (held %.1f s)" % hold) + self.key("Return", hold) + tr = time.time() + time.sleep(0.7) + raise Timeout("timeout (%d s) waiting for save slot screen" % timeout) + + @staticmethod + def _slot_ok(text): + return fuzzy_in("kapitel", text) and not fuzzy_in("leer", text) + + @staticmethod + def _slot_no(text): + """Slot number from "gespeichertes spiel N" (OCR text), or None.""" + m = re.search(r"spiel\s*([123])\b", text) + return int(m.group(1)) if m else None + + def _select_slot(self, slot=3, timeout=240): + """Turns the save slot carousel (it opens on slot 2) to `slot` and checks its text after every step. + The carousel wraps (3 -> 1) and ignores keys while it turns, so Right is pressed once per step and + only again when the text shows another slot. A step that did not move is repeated with a longer + hold (0.3/0.6/1.0/1.5 s). On a busy machine (load ~30, several games) the carousel ignored + Left/Right for minutes while Return/Escape worked: `run` therefore uses slot 2, which needs no + turning.""" + self.wait_stable(still=0.8, timeout=6) + holds = [0.3, 0.6, 1.0, 1.5] + t0 = time.time() + t = self.slot_text() + while time.time() - t0 < timeout: + if self._slot_no(t) == slot: + log("slot: %s" % t.split(" | ")[0]) + return t + if not fuzzy_in("gespeichertes", t): + raise Timeout("save slot screen lost while selecting slot %d (%s)" % (slot, t)) + before = self._slot_no(t) + self.key("Right", holds[0]) + t1 = time.time() + while time.time() - t1 < 8: # the carousel turns, then the new text shows + time.sleep(0.6) + t = self.slot_text() + if self._slot_no(t) not in (None, before): + break + else: + if len(holds) > 1: + holds.pop(0) + log("slot carousel did not move (slot %s) -> Right again, held %.1f s" % (before, holds[0])) + raise Timeout("save slot %d not selected after %d s (%s)" % (slot, timeout, t)) + + def _open_slot(self): + """Return on the slot until the slot screen is gone (a short press can be missed under load).""" + for hold in (0.6, 0.9, 1.2): + self.key("Return", hold) + t1 = time.time() + while time.time() - t1 < 6: + time.sleep(0.7) + if not fuzzy_in("gespeichertes", self.slot_text()): + return + log("still on the save slot screen -> Return again") + raise Timeout("save slot did not open") + def status(self): s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(), "window": self.find_window() if self.display and self.state.get("xvfb_pid") else None, @@ -606,18 +691,53 @@ class Session: return s -def run(sess, mods, opts, level, seq, out, keep=False): +def start_to_level(sess, level, out, report, retry=True): + """start + tolevel; a crash before the main menu (Wine page faults at start-up, not caused by mods) or a + menu that was not recognised is retried once with a fresh game start. Each retry is listed in + report["retries"] and logged; the failed attempt's screenshot/log go to out/retry1_*.""" + for attempt in (1, 2): + phase = "start" + try: + sess.start() + if level: + phase = "menu" + report["tolevel"] = sess.tolevel(world=level[0], slot=2, + on_menu=lambda: report.update(menu_reached=True)) + return + except (Crash, Timeout) as e: + early = isinstance(e, Crash) and not report.get("menu_reached") or \ + isinstance(e, Timeout) and "save slot" in str(e) + if not retry or attempt == 2 or not early: + raise + log("WARNING: %s during %s -> stopping and starting the game once more" % (e, phase)) + try: + sess.shot(os.path.join(out, "retry1_failure.jpg"), small=True) + except SystemExit: + pass + gl = os.path.join(sess.data, "logs", "game.log") + if os.path.exists(gl): + shutil.copy(gl, os.path.join(out, "retry1_game.log")) + report.setdefault("retries", []).append({"attempt": attempt, "phase": phase, "error": str(e)}) + report.pop("menu_reached", None) + sess.stop() + + +def run(sess, mods, opts, level, seq, out, keep=False, retry=True): os.makedirs(out, exist_ok=True) - report = {"mods": mods, "opts": opts, "level": level} + # a busy machine (several agents' games) slows the game to a few frames per second: the usual cause of + # missed key presses; recorded to tell flaky runs apart + report = {"mods": mods, "opts": opts, "level": level, "load_avg": [round(x, 1) for x in os.getloadavg()], + "cpus": os.cpu_count()} code = 0 try: sess.setup() report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:] if level: + # slot 2 is where the carousel opens (no turning, see _select_slot); slot 3 gets the same + # progress for mods whose "save slot" option defaults to 3 + sess.unlock(2, level[0], level[1]) sess.unlock(3, level[0], level[1]) - sess.start() - if level: - report["tolevel"] = sess.tolevel(world=level[0]) + start_to_level(sess, level, out, report, retry) sess.shot(os.path.join(out, "00_start.png")) if seq: report["shots"] = sess.keys(seq, outdir=out) @@ -647,7 +767,8 @@ def run(sess, mods, opts, level, seq, out, keep=False): json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1) if not keep: sess.stop() - log("result: %s" % (report.get("error") or "OK")) + retried = "" if not report.get("retries") else " (after a retry: %s)" % report["retries"][0]["error"] + log("result: %s%s" % (report.get("error") or "OK", retried)) return code @@ -660,7 +781,8 @@ def main(argv): i = 0 while i < len(rest): a = rest[i] - if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--seq", "--out") and i + 1 < len(rest): + if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--slot", "--seq", "--out") \ + and i + 1 < len(rest): kw[a[2:]] = rest[i + 1] i += 2 elif a == "--opt" and i + 1 < len(rest): @@ -685,7 +807,8 @@ def main(argv): elif cmd == "start": s.start() elif cmd == "tolevel": - print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None))) + print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None, + slot=int(kw.get("slot", 3))))) elif cmd == "keys": print(s.keys(pos, outdir=kw.get("out"))) elif cmd == "shot": @@ -703,7 +826,8 @@ def main(argv): print("run needs --display N and --out DIR") return 2 mods = [x for x in kw.get("mods", "").split(",") if x] - return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos) + return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos, + retry="--no-retry" not in pos) else: print(__doc__) return 2 diff --git a/tools/geom_export.py b/tools/geom_export.py index e712125..7544efc 100644 --- a/tools/geom_export.py +++ b/tools/geom_export.py @@ -9,7 +9,8 @@ Writes to outdir: page_NN.png texture pages (256x256, from TEXT) collision.obj (--collision) collision polygons, grouped by material objects.obj (--objects) static STPC object models side by side - preview_top.png, preview_persp.png textured software renderings for checking + preview_top.png, preview_persp.png textured software renderings for checking (game space, + the same way round as in the game) Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes @@ -101,6 +102,11 @@ def write_mtl(path, tris, texs, npages): # ------------------------------------------------------------ software renderer +def game_space_tris(tris): + """world_tris (export space) -> game space for the right-handed preview camera.""" + return [([G.to_file(p) for p in P], *rest) for P, *rest in tris] + + def render(tris, pages, cam, size, path, texs): Wd, Hd = size eye, target, fov = cam @@ -237,10 +243,13 @@ def main(): for k, im in enumerate(imgs): Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png')) size = tuple(int(s) for s in a.size.split('x')) + # the renderer's camera is right-handed: game space shows the level as the game does + # (export space would come out mirrored); also with --export-space + tris = game_space_tris(tris) P = np.array([p for t in tris for p in t[0]]) lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2 ext = max(hi[0] - lo[0], hi[2] - lo[2]) - # top view (almost vertical, north = -z at the top) + # top view (almost vertical, game -z at the top) render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size, os.path.join(a.outdir, 'preview_top.png'), texs) render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size, diff --git a/tools/test_apptest.py b/tools/test_apptest.py new file mode 100644 index 0000000..4ede2e7 --- /dev/null +++ b/tools/test_apptest.py @@ -0,0 +1,86 @@ +"""Offline tests of apptest's retry/slot logic (no game needed): python3 -m unittest tools/test_apptest.py""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import apptest as A # noqa: E402 + + +class FakeSession: + def __init__(self, outcomes): + self.outcomes = list(outcomes) # per start: None ok, or exception raised by start/tolevel + self.data = tempfile.mkdtemp() + self.starts = self.stops = 0 + + def start(self): + self.starts += 1 + o = self.outcomes.pop(0) + if isinstance(o, A.Crash) and "start" in str(o): + raise o + self._next = o + + def tolevel(self, world=None, slot=3, on_menu=None): + o = self._next + if isinstance(o, A.Crash) and "level" in str(o): + on_menu() + raise o + if o: + raise o + on_menu() + return {"slot": slot} + + def shot(self, path, small=False): + open(path, "w").close() + + def stop(self): + self.stops += 1 + + +class StartRetryTest(unittest.TestCase): + def setUp(self): + self.out = tempfile.mkdtemp() + + def test_early_crash_is_retried_once_and_reported(self): + s, rep = FakeSession([A.Crash("start: page fault"), None]), {} + A.start_to_level(s, (1, 1), self.out, rep) + self.assertEqual(2, s.starts) + self.assertEqual([{"attempt": 1, "phase": "start", "error": "start: page fault"}], rep["retries"]) + self.assertEqual({"slot": 2}, rep["tolevel"]) + self.assertTrue(os.path.exists(os.path.join(self.out, "retry1_failure.jpg"))) + + def test_missed_slot_screen_is_retried(self): + s, rep = FakeSession([A.Timeout("timeout (45 s) waiting for save slot screen"), None]), {} + A.start_to_level(s, (1, 1), self.out, rep) + self.assertEqual("menu", rep["retries"][0]["phase"]) + + def test_crash_after_the_menu_is_not_retried(self): + s, rep = FakeSession([A.Crash("level: page fault"), None]), {} + with self.assertRaises(A.Crash): + A.start_to_level(s, (1, 1), self.out, rep) + self.assertEqual(1, s.starts) + self.assertNotIn("retries", rep) + + def test_second_failure_and_no_retry_raise(self): + s = FakeSession([A.Crash("start: a"), A.Crash("start: b")]) + with self.assertRaises(A.Crash): + A.start_to_level(s, None, self.out, {}) + self.assertEqual(2, s.starts) + s = FakeSession([A.Crash("start: a"), None]) + with self.assertRaises(A.Crash): + A.start_to_level(s, None, self.out, {}, retry=False) + + +class SlotTextTest(unittest.TestCase): + def test_slot_number_and_content(self): + S = A.Session + self.assertEqual(2, S._slot_no(". gespeichertes spiel 2 u leer | gespeichertes spiel 2 u leer")) + self.assertEqual(3, S._slot_no("gespeichertes spiel 3 dorf kapitel 1")) + self.assertIsNone(S._slot_no("eingabe neues spiel starten")) + self.assertTrue(S._slot_ok("gespeichertes spiel 2 u testlevel kapitel 1 goldmünzen")) + self.assertFalse(S._slot_ok("gespeichertes spiel 2 u leer")) + + +if __name__ == "__main__": + unittest.main()