From 44e0aac803990c1669403a5cba4fbfbbe62c9aa0 Mon Sep 17 00:00:00 2001 From: moshehbenavraham Date: Mon, 7 Sep 2026 12:01:26 +0300 Subject: [PATCH 1/5] Prepare output spacing and ASCII map fixes Add the investigation and fix plan, update the task list, add the environment template and ignore rule, and remove the old coverage baseline. --- .env.example | 6 + .gitignore | 5 +- coverage/baselines.json | 20 -- .../OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md | 338 ++++++++++++++++++ docs/ongoing-projects/TASK_LIST.md | 7 + 5 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 .env.example delete mode 100644 coverage/baselines.json create mode 100644 docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..372d892 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Example Environment file for the application, copy as `.env` and fill in the values + +# if you want a player account with "Forger" (highest staff character) +# access ... this works for both production and local-dev +GAME_MASTER_ACCOUNT= +GAME_MASTER_ACCOUNT_PASSWORD= diff --git a/.gitignore b/.gitignore index e230c0f..7da5f0c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ Thumbs.db *_backup/ # Local configuration -*.local \ No newline at end of file +*.local + +# Environment files +.env \ No newline at end of file diff --git a/coverage/baselines.json b/coverage/baselines.json deleted file mode 100644 index a71ae38..0000000 --- a/coverage/baselines.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema_version": 1, - "policy": "Informational history only; these values are not thresholds.", - "history": [ - { - "date": "2026-08-05", - "label": "Phase 4 initial local baseline", - "lua": { - "covered_lines": 860, - "num_statements": 2833, - "percent_covered": 30.36 - }, - "python": { - "covered_lines": 1489, - "num_statements": 2892, - "percent_covered": 51.49 - } - } - ] -} diff --git a/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md b/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md new file mode 100644 index 0000000..4b78668 --- /dev/null +++ b/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md @@ -0,0 +1,338 @@ +# Preserve output spacing and capture complete ASCII maps + +Date: 2026-09-07 + +Status: Investigation and reproduction complete; implementation pending. + +GUI baseline: `ab33a52`, package `2.0.4.045`. + +## Outcome + +Both reported problems are reproducible. The GUI deletes every empty line, +independently of the server's compact preference. Its temporary map trigger +also starts one row late in Mudlet 4.22.0, leaving the first row in the main +console and copying only eight rows of a nine-row room map. The fixed capture +length causes a separate truncation problem for taller maps. + +The exact reported room was checked on the user-provided local server, +`127.0.0.1:4100`: room `145202`, immediately south of `145201`. The server +sent the complete map, including ` [.]-[|]-[.]` on its first row. Replaying +that captured output through the unchanged production triggers in native +Mudlet 4.22.0 reproduced the reported leak and missing row. + +This document is the requested fix plan. No GUI source, generated XML, +package version, or server source was changed during this investigation. + +## Evidence and reproduction + +### Setup and limits + +- Read `docs/MUDLET_COMPATIBILITY.md` before diagnosis. Its documented target + is 4.22.0; that is the primary reproduction version, not a claim about the + newest upstream release. +- `python3 theGUI/build.py --diff --fail-on-diff` passed, establishing that + the checked-in package matches the source fragments. +- Used the official Linux Mudlet 4.22.0 AppImage under Xvfb, with a disposable + portable profile. The archive's SHA-256 matched the repository CI pin: + `8f10a78ab918d4b46b1f842c1ca7522b9c26aa8200f657bd8fd5ccba8a7c9040`. +- Imported `theGUI/src/triggers/01_gui.xml` unchanged into the isolated + profile. Matching, temporary-trigger scheduling, selection, copying, + deletion, and the destination miniconsole were real Mudlet operations. + GUI visibility and font-sizing dependencies were stubbed to isolate text + capture. These results establish a capture defect, not full-layout QA. +- Captured live Telnet text separately, with GUI mode both off and on and + compact mode both off and on. Replayed the ANSI-bearing text with + `feedTriggers()` after normalizing CRLF to LF. Direct `feedTriggers()` is + not the Telnet decoder: feeding CRLF directly introduced artificial empty + lines in an initial trial; those results were excluded. +- Read main-console and miniconsole buffers with `getLines()` and recorded + every map-row callback. A complete block and a replay split at each newline + produced the same first-row failure. Arbitrary TCP fragmentation and a + full-package live-Mudlet session remain implementation acceptance tests. +- A comparison run reported Mudlet 5.0.1 and captured all nine rows. The + disposable runtime's automatic updater had changed the executable between + runs. Its result was kept separately; automatic downloads were then + disabled, the verified 4.22.0 image re-extracted, and the live captures + replayed again with `getMudletVersion("string")` confirming 4.22.0. +- Restored the local test character to room `1204` and its original + preferences: GUI mode off, compact off, automap on, brief off. Verified + those settings and completed character and account logout. Authentication + data is excluded from this document and the replay fixtures. + +The user's installed Mudlet version has not yet been established. The +version-specific reproduction above should be compared with that version +when implementing the fix. The supplied server checkout was at `2fa02cca6`; +the running listener used a binary under its `bin/releases/` directory, so +checkout identity alone is not proof of the running binary's source revision. +The live output independently confirms the relevant map framing and spacing. + +### Observed results + +Counts below refer to actual map rows, excluding the extra leading newline +that the existing room-map trigger adds to its destination window. + +| Input / control | Mudlet 4.22.0 result | +|---|---| +| `A`, blank, ` indented words`, blank, `B`; blank gag enabled | Both empty lines removed; indentation and spaces between words preserved. | +| Same input; blank gag disabled | Both empty lines and all spaces preserved. | +| Live move `145201 -> 145202`, GUI mode on, compact off | Server sent 9 rows; GUI copied rows 2-9 and left row 1 in the main console. | +| Same live output; blank gag disabled | Still only 8 map rows copied; both non-map blank lines survived instead of zero. | +| Live `look` in `145202`, compact on | Still only 8 map rows copied. With blank gag disabled, one non-map blank line remained, versus two with compact off. | +| Live map for room `145201` | First row was 19 spaces; it remained in the main console. Only 8 rows reached the map window. | +| Synthetic 13-row room map | Copied rows 2-12; row 1, row 13, and `` remained in the main console. | +| Synthetic 21-row wilderness map | Copied rows 2-21; row 1 remained in the main console. | + +The 5.0.1 comparison copied all nine live room-map rows, while its blank-line +gag still removed the non-map empty lines. The 13-row room-map fixture still +exceeded the fixed capture length. Changing the client version therefore +does not resolve the complete scope of these issues. + +### Exact room-map fixture + +This is the color-stripped map sent by the local server for room `145202`, +represented as a JSON array so trailing spaces are explicit. Leading spaces +and empty-looking rows are map data. The first row has four leading and four +trailing spaces; its text width is 19 characters. + +```json +[ + "", + " [.]-[|]-[.] ", + " | ", + " [.]-[C]-[.] [.]", + " | | ", + " [.]-[&]-[.] [Y]", + " | | ", + " [.]-[C]-[,]-[Y]", + " | | ", + "[-]-[-]-[,]-[,]-[.]", + "" +] +``` + +For the same local world, room `145201` begins with **two rows of 19 spaces** +before the visible `[.]-[|]-[.]` row. Losing its first row is much less +noticeable. That provides a concrete explanation for the apparent +intermittency without requiring packet loss or a server omission. + +To repeat the live route with a staff test character, record its current +room/preferences first, then use `toggle automap on`, `toggle brief off`, +`toggle compact off`, `toggle guimode on`, `goto 145201`, `south`, and `look`. +Compare the raw stream with the GUI's main and ASCII-map buffers. Repeat with +compact on, and restore the original room/preferences afterward. + +## Causes + +### 1. An unconditional blank-line gag overrides the server's spacing + +In [the GUI triggers](../../theGUI/src/triggers/01_gui.xml), the active +`Gag blank lines` trigger at lines 178-197 matches `^$` and immediately calls +`deleteLine()`. It has no compact-mode check, user preference, or map-capture +condition. It removes blank lines from descriptions, menus, and prompt +separation throughout the main console. + +The server explicitly adds a CRLF when compact mode is off in +`Luminari-Source/src/comm.c:3747-3753`. The GUI subsequently removes the +resulting empty line, explaining why turning compact off appears ineffective. + +The evidence does **not** show removal of ordinary spaces within lines. +There is also a deliberate server-side formatting difference to control for: +`src/act.informative.c:1618-1634` sends tagged maps plus the original room +description in GUI mode, but calls `str_and_map()` otherwise. +`src/asciimap.c:704-708` wraps/reformats the description for a side-by-side +map. A generic Telnet comparison with different GUI/automap settings is +therefore not expected to have identical horizontal layout. + +### 2. Map capture depends on version-sensitive temporary-trigger timing + +The same GUI trigger fragment creates: + +```lua +-- Capture Wilderness Map, line 29 +map.maplineTrig = tempLineTrigger(1, 23, [[onMapLine()]]) +-- Capture Room Map, line 114 +map.maplineTrig = tempLineTrigger(1, 11, [[onRoomMapLine()]]) +``` + +On the verified 4.22.0 runtime, a line trigger created inside the opening +marker's callback with `from = 1` skips the first following row. A separate +three-line engine probe confirmed the scheduling difference: + +| Trigger created while processing `START`, followed by `FIRST`, `SECOND` | 4.22.0 callback sees | 5.0.1 callback sees | +|---|---|---| +| `tempLineTrigger(1, 1, callback)` | `SECOND` | `FIRST` | +| `tempLineTrigger(0, 1, callback)` | `FIRST` | `START` | + +The [tagged trigger dispatcher source](https://github.com/Mudlet/Mudlet/blob/Mudlet-4.22.0/src/TriggerUnit.cpp#L284) +iterates a snapshot of the trigger list. A newly created temporary trigger +does not see the opening-marker line. The +[line-trigger matcher](https://github.com/Mudlet/Mudlet/blob/Mudlet-4.22.0/src/TTrigger.cpp#L910) +decrements its start counter before checking for a value below zero. Together +these explain the measured delay. This differs from the +[manual's documented next-line behavior for `from = 1`](https://wiki.mudlet.org/w/Manual:Lua_Functions#tempLineTrigger). +Runtime evidence takes precedence for this diagnosis. + +Do not implement a universal `1 -> 0` replacement: the comparison run shows +that it can capture the opening marker on another client version. The fix +should remove this timing dependency. + +### 3. Fixed row counts and incomplete boundaries compound the capture bug + +The server supplies explicit `` / `` and +`` / `` boundaries. See +`src/act.informative.c:1622-1628` and +`src/wilderness/wilderness.c:1630-1639,1653-1662`. + +Room height is `2 * CONFIG_MINIMAP_SIZE + 1`, from +`src/asciimap.c:500-520`. The checkout configuration has size 4, giving the +nine rows observed live; the configuration editor permits sizes 1-12 +(`src/olc/cedit.c:3412`), giving 3-25 rows. A fixed 11-callback trigger can +expire before a valid map ends and never process its closing marker. + +Related paths to cover in the same fix: + +- Both parsers reject `line == ""` before considering it as a map row, + printing a warning into the main console. Space-only rows pass that check, + but truly empty rows do not. Preserve both deliberately. +- Both capture starts overwrite the shared `map.maplineTrig` without first + canceling an existing capture. Overlapping/interrupted blocks can lose + ownership of the previous trigger. This is a code-path risk, not a claim + that it caused the observed first-row loss. +- The success path can delete a row even when `map.minimap` is unavailable + and no append took place. A destination failure must not discard content. +- Font sizing assumes 20 columns by 11 rows for room maps in + `theGUI/src/scripts/00_msdpmapper.xml:294-305`. Removing the capture limit + also requires sizing the destination for the actual completed map. + +## Implementation plan + +### Step 1: Preserve ordinary server spacing + +Remove the unconditional `Gag blank lines` trigger from +`theGUI/src/triggers/01_gui.xml`. Preserve empty and whitespace-only lines +outside map capture by default. Let the server's compact preference control +prompt spacing. No new client compact setting is needed for this fix. + +Verify that intentionally relocated chat still follows the existing chat-gag +preference and that normal room text, menus, indentation, and prompt spacing +survive. Map-specific blank rows must be handled by the map parser itself. + +### Step 2: Use one permanent parser with explicit map boundaries + +Replace the two start-trigger bodies and their dynamically created line +triggers with a single permanent line dispatcher that is present before +incoming output is processed. Verify its pattern fires once for every +logical line, including an empty line, in real Mudlet. Put the small +namespaced parser in a focused child such as +`theGUI/src/scripts/gui/37_ascii_map_capture.xml`, included explicitly by +`theGUI/src/scripts/01_gui.xml` before boot/lifecycle scripts. + +The parser should implement these transitions: + +| State / input | Required action | +|---|---| +| Idle / ordinary line | Leave the line untouched. | +| Opening room or wilderness marker | End any old capture, validate the destination, set the map kind, clear the destination once, and consume the marker when capture is available. | +| Capturing / valid map row, including blanks | Copy formatting and the complete row to the miniconsole; delete the main-console row only after successful transfer. Track actual row count and visible width. | +| Capturing / matching closing marker | Consume the marker, finish the map, clear capture state, and cancel its timeout. | +| Capturing / another opening marker | Cancel the old capture and start the new block exactly once. | +| Capturing / unexpected text, mismatched end, timeout, or safety limit | Cancel capture before consuming unrelated text; leave that text readable. | + +Use the closing marker to determine completion. Row/byte limits and an +inactivity timer are recovery guards, not map-height assumptions. Size those +guards above supported server output; test delayed delivery before choosing +the timeout. Validate room and wilderness row shapes separately, including +their permitted symbols and blank rows, so a missing end marker cannot move +an ordinary room description or prompt into the map window. + +Retain ANSI colors through native selection/copy/append operations. Preserve +leading/trailing spaces and blank rows. Account explicitly for any deliberate +display padding; avoid adding an extra data row at the top. If the destination +is unavailable or transfer fails, keep the incoming map readable in the main +console and reset capture safely. + +### Step 3: Complete sizing, cleanup, and upgrade behavior + +- Fit the map using measured visible width and row count. Verify first and + last rows remain visible when resizing the ASCII window and when displaying + supported taller maps. Preserve room/wilderness map-mode controls. +- Replace legacy `map.maplineTrig` cleanup with one namespaced capture-reset + function. Cancel a retained legacy ID during migration. Reset state on + reconnect, profile reset, GUI refresh, package replacement, and uninstall. +- Integrate with the existing lifecycle registries and + `GUI.cleanup()` in `gui/01_preferences.xml:158-163`. Any recovery timer must + use `GUI.setOwnedTimer()` and a stable name. Add no untracked handlers or + raw runtime `tempTimer()` calls. +- Remove obsolete global `onMapLine()` / `onRoomMapLine()` callbacks and old + start triggers so there is only one owner of each incoming map block. + +### Step 4: Add regression coverage and verify in real Mudlet + +Add a source-linked capture suite, for example +`tests/test_output_capture.py`, and register it with `tests/run_tests.py`. +Keep parser-unit assertions separate from real engine-scheduling checks; +calling a Lua callback directly cannot prove which incoming row invokes it. + +Required acceptance cases: + +- The exact nine-row `145202` fixture appears completely in the ASCII + miniconsole, once and in order. No marker or ASCII-map row remains in the + main console; movement, description, exits, and prompt remain readable. +- Room `145201` retains both initial space-only rows inside the map. Test + truly empty first/interior/final rows as well. +- Cover room heights 3, 9, 11, 13, and 25 and a 21-row wilderness map. First + and last rows must survive and the closing marker must always end capture. +- Repeat with compact off/on, GUI mode off/on, automap off/on, brief off/on, + both map-view modes, chat gag off/on, ANSI colors, and a narrow window. + Compare ordinary output against the same server settings and width. +- Compare normalized non-map output with the raw fixture: empty-line count, + indentation, repeated spaces, and prompt separation must match, accounting + only for explicitly relocated chat. Do not broadly trim whitespace in the + test oracle. +- Test multiple maps in one input batch, newline-separated delivery, + arbitrary TCP splits (including within markers and CRLF), blank bursts, + and delayed map rows. Network tests must use Mudlet's actual Telnet path. +- Test missing/mismatched end markers, consecutive starts, missing widgets, + append failures, and oversized blocks. Ordinary text after an aborted + capture must survive and the next valid map must recover. +- Repeat refresh/reconnect/reset/replacement/uninstall while capture is + active. No stale trigger, timer, duplicate row, or active capture may + survive its cleanup boundary. +- Run native integration checks on supported 4.21 and 4.22, plus the user's + actual version. Repeat the 5.0.1 comparison if it is in the support matrix. + Record the runtime version inside each test result and disable automatic + updates in disposable profiles to keep comparisons attributable. + +### Step 5: Build and hand off the implemented fix + +After source changes and focused tests are ready, validate and build once, +then run the repository gates: + +```bash +python3 theGUI/build.py --validate +python3 theGUI/build.py +python3 theGUI/build.py --diff --fail-on-diff +python3 tests/run_tests.py --skip-optional +python3 scripts/validate_package.py +python3 scripts/analyze_handlers.py --fail-on-unowned +``` + +Review the source fragments, manifest, generated XML, archive, and tests +together. Add the spacing and map-boundary cases to +`docs/MUDLET_SMOKE_TEST.md` and record the client timing finding in +`docs/MUDLET_COMPATIBILITY.md`. A release still requires that manual smoke +checklist; this investigation does not constitute release approval. + +## Completion criteria + +The fix is complete when matching server configurations produce matching +non-map whitespace; every valid tagged map is transferred completely and +exactly once; no map data leaks into the main console during successful +capture; failure recovery preserves ordinary text; and these behaviors hold +across supported client versions and lifecycle transitions. + +Investigation artifacts are available locally under +`/tmp/luminari-output-investigation.CXgbf8/`: `prepare_replay.py`, `replay.lua`, +`live-capture.json`, and version-labelled `results-4.22.0.json` / +`results-5.0.1.json`. That temporary directory is not a durable test dependency; +the implementation must check in sanitized fixtures and its regression suite. diff --git a/docs/ongoing-projects/TASK_LIST.md b/docs/ongoing-projects/TASK_LIST.md index 823c22c..d457462 100644 --- a/docs/ongoing-projects/TASK_LIST.md +++ b/docs/ongoing-projects/TASK_LIST.md @@ -162,6 +162,13 @@ in this folder. ## Feature backlog +- [ ] Restore normal output spacing and capture complete ASCII maps using + [the investigation and fix plan](OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md). + - Investigated 2026-09-07: the global blank-line gag overrides compact mode; + native Mudlet 4.22.0 replay of local room `145202` captures 8 of 9 map + rows, with the first row leaking into the main console. Fixed capture + lengths also truncate taller maps. Implementation and regression coverage + remain pending. - [x] Expand sound support beyond chat notifications using a small native subsystem - Completed 2026-08-05: `GUI.Sound` centralizes tagged Mudlet media playback, safe profile/package file resolution, persistence, master/per-channel From bb18aec9eee9f2e682e9fe31d60356f745ec2fc1 Mon Sep 17 00:00:00 2001 From: moshehbenavraham Date: Mon, 7 Sep 2026 13:37:46 +0300 Subject: [PATCH 2/5] Fix output spacing and tagged map capture --- LuminariGUI.xml | 505 +- docs/MUDLET_COMPATIBILITY.md | 26 +- docs/MUDLET_SMOKE_TEST.md | 41 + docs/RESOURCE_LIFECYCLE.md | 16 +- docs/archive/LuminariGUI.xml_2.0.4.045 | 7031 +++++++++++++++++ .../OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md | 62 +- tests/run_tests.py | 5 +- tests/test_lifecycle_regressions.py | 26 +- tests/test_output_capture.py | 501 ++ theGUI/build.yaml | 2 +- theGUI/src/scripts/00_msdpmapper.xml | 12 +- theGUI/src/scripts/01_gui.xml | 1 + theGUI/src/scripts/gui/01_preferences.xml | 16 +- .../src/scripts/gui/37_ascii_map_capture.xml | 301 + theGUI/src/scripts/gui/52_refresh.xml | 3 + theGUI/src/scripts/gui/53_lifecycle.xml | 3 + theGUI/src/triggers/01_gui.xml | 168 +- 17 files changed, 8339 insertions(+), 380 deletions(-) create mode 100644 docs/archive/LuminariGUI.xml_2.0.4.045 create mode 100644 tests/test_output_capture.py create mode 100644 theGUI/src/scripts/gui/37_ascii_map_capture.xml diff --git a/LuminariGUI.xml b/LuminariGUI.xml index a62c8b5..ea299e8 100644 --- a/LuminariGUI.xml +++ b/LuminariGUI.xml @@ -1,6 +1,6 @@ - + LuminariGUI @@ -255,149 +255,9 @@ end - Capture Wilderness Map - - 0 - 0 - 0 - - - #ff0000 - #ffff00 - - #000000 - #000000 - - <WILDERNESS_MAP> - - - 2 - - - - Capture Room Map - 0 0 @@ -410,27 +270,7 @@ end #000000 #000000 - <ROOM_MAP> - - - 2 - - - - Gag blank lines - - 0 - 0 - 0 - - - #ff0000 - #ffff00 - - #000000 - #000000 - - ^$ + ^.*$ 1 @@ -1606,29 +1446,29 @@ function map.load_map(use_local) end end -function map.adjustMinimapFontSize() +function map.adjustMinimapFontSize(columns, rows) local w = map.minimap.get_width() local h = map.minimap.get_height() local font_size = 8 repeat font_size = font_size + 1 local width, height = calcFontSize(font_size) - width = width * map.minimap_width - height = height * map.minimap_height + width = width * (columns or map.minimap_width) + height = height * (rows or map.minimap_height) until (w < width) or (h < height) map.minimap_font_size = font_size - 1 setMiniConsoleFontSize("map.minimap", map.minimap_font_size) end -function map.adjustAsciimapFontSize() +function map.adjustAsciimapFontSize(columns, rows) local w = map.minimap.get_width() local h = map.minimap.get_height() local font_size = 8 repeat font_size = font_size + 1 local width, height = calcFontSize(font_size) - width = width * 20 - height = height * 11 + width = width * (columns or 20) + height = height * (rows or 11) until (w < width) or (h < height) map.minimap_font_size = font_size - 1 setMiniConsoleFontSize("map.minimap", map.minimap_font_size) @@ -2221,19 +2061,21 @@ function GUI.cleanup() if demonnic and demonnic.chat and demonnic.chat.stopBlinking then demonnic.chat:stopBlinking() end - local timersCanceled = GUI.cancelAllOwnedTimers() - GUI.castConsoleTimer = nil - GUI.initCallPending = false - GUI.msdpReportRequestPending = false - GUI.refreshCallsPending = {} - - if map and map.maplineTrig then + if GUI.AsciiMapCapture and type(GUI.AsciiMapCapture.reset) == "function" then + pcall(GUI.AsciiMapCapture.reset, "GUI cleanup") + elseif map and map.maplineTrig then if exists(map.maplineTrig, "trigger") ~= 0 then pcall(killTrigger, map.maplineTrig) end map.maplineTrig = nil end + local timersCanceled = GUI.cancelAllOwnedTimers() + GUI.castConsoleTimer = nil + GUI.initCallPending = false + GUI.msdpReportRequestPending = false + GUI.refreshCallsPending = {} + GUI.saveToggles() return {timers = timersCanceled} end @@ -3816,6 +3658,307 @@ end end + + + + 0 + 0 + 0 + + LuminariGUI + #ff0000 + #ffff00 + + #000000 + #000000 + + + + YATCOConfig + + 0 + 0 + 0 + + YATCOConfig + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Tell + + 0 + 39 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) tells you, '(.*)' + You tell + + + 1 + 2 + + + + Congrats + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) congrats, '(.*) + You congrat, ' + + + 1 + 2 + + + + Chat + + 0 + 39 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) chats, '(.*) + You chat, ' + + + 1 + 2 + + + + Say + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You say, ' + You shout, ' + You holler, ' + You whisper to + You ask + You say out-of-character, ' + ^(\w+) says, ' + ^(\w+) shouts, ' + ^(\w+) hollers, ' + whispers to you, ' + asks you, ' + ^(\w+) says out-of-character, ' + + + 2 + 2 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 2 + 1 + + + + Auction + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) auctalks, '(.*) + You auctalk, ' + + + 1 + 2 + + + + Group + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + [Group] + You group-say, ' + + + 2 + 2 + + + + Wiznet + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + [wiznet] + + + 2 + + + + + GUI + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Capture Wilderness Map + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + <WILDERNESS_MAP> + + + 2 + + + + Capture Room Map + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + <ROOM_MAP> + + + 2 + + + + Gag blank lines + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^$ + + + 1 + + + + Cast Console + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Started Cast + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^Casting\: (.+) (\*+)$ + + + 1 + + + + Cast Complete + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You complete your spell... + + + 2 + + + + Cast Aborted + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You abort your spell. + Your spell is aborted! + You are unable to find the target for your spell! + You are unable to find the object for your spell! + You are unable to continue your spell in your current position! + + + 3 + 3 + 3 + 3 + 3 + + + + Cast Canceled + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You are unable to continue casting! + You are too nauseated to continue casting! + + + 3 + 3 + + + + + + + + + + LuminariGUI + + + LuminariGUI + + + Toggles + + + + + + Gag Chat + + + + ^gag chat$ + + + Show Self + + + + ^show self$ + + + Horizontal Scroll + + + + ^hscroll$ + + + Sound + + + + ^sound(?:\s+(.*))?$ + + + + YATCO + + + YATCO + + + Demonnic + + + + + + Shared + + + + + + Reset chasing + + + + ^chaseres$ + + + Fix GUI Events + + + + ^fix gui$ + + + Debug + + + + ^debug(?: (list))?$ + + + debug categories + + + + ^debugc(?: (.*))?$ + + + + Tabbed Chat + + + + + + Toggle blinking (temporary change) + + + + ^dblink$ + + + dsound + + + + ^dsound$ + + + set chat sound + + + + ^set chat sound(?:\s+(.*))?$ + + + fixChat + + + + ^fix chat$ + + + + + + + + + + LuminariGUI + LuminariGUI + + + + LuminariDebug + LuminariGUI + + + + + + + + LuminariResources + LuminariGUI + + + + + + + + AdjustableContainersFoundation + LuminariGUI + + + + + + + + MSDPMapper + + + + + + + + + GUI + Template + + + + CSSman + CSSman + + + + + + + + GUI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + YATCOConfig + YATCOConfig + + + + YATCOCONFIG + + + + + + + + + + YATCO + YATCO + + + + Demonnic + + + + + Shared + + + + + + + + + + + + Tabbed Chat + + + + + + + + + sysLoadEvent + + + + + + + + sysInstall + + + + + + + LuminariDebugInstrumentation + LuminariGUI + + + + + + + + + + + Movement + + + + -1 + -1 + + Southwest + + + southwest + 49 + 536870912 + + + South + + + south + 50 + 536870912 + + + Southeast + + + southeast + 51 + 536870912 + + + West + + + west + 52 + 536870912 + + + Look + + + look + 53 + 536870912 + + + East + + + east + 54 + 536870912 + + + Northwest + + + northwest + 55 + 536870912 + + + North + + + north + 56 + 536870912 + + + Northeast + + + northeast + 57 + 536870912 + + + Inventory + + + inv + 47 + 536870912 + + + Scan + + + scan + 42 + 536870912 + + + Up + + + up + 45 + 536870912 + + + Down + + + down + 43 + 536870912 + + + + + + + diff --git a/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md b/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md index 4b78668..68e4d31 100644 --- a/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md +++ b/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md @@ -2,7 +2,8 @@ Date: 2026-09-07 -Status: Investigation and reproduction complete; implementation pending. +Status: Implemented and verified on `fix/output-spacing-ascii-map`; this is not +a release approval. GUI baseline: `ab33a52`, package `2.0.4.045`. @@ -20,8 +21,49 @@ sent the complete map, including ` [.]-[|]-[.]` on its first row. Replaying that captured output through the unchanged production triggers in native Mudlet 4.22.0 reproduced the reported leak and missing row. -This document is the requested fix plan. No GUI source, generated XML, -package version, or server source was changed during this investigation. +The implementation below now replaces both faulty trigger paths. The initial +investigation itself was read-only; subsequent progress entries distinguish +the source, generated package, tests, and documentation changed for the fix. + +## Implementation progress + +- 2026-09-07: Scope review retained the boundary parser, recovery guard, + lifecycle cleanup, measured sizing, regression coverage, and native-client + checks because each protects a reproduced defect or an explicit failure + case. The implementation uses the existing permanent trigger tree as the + single line dispatcher and a focused GUI parser script; a second event or + handler abstraction was removed from the design as unnecessary. +- 2026-09-07: Removed the unconditional blank-line gag and both dynamic map + triggers. Added one permanent logical-line dispatcher plus the focused + `GUI.AsciiMapCapture` boundary parser. It validates room/wilderness rows, + preserves ANSI formatting and exact whitespace, measures completed maps, + uses 64-row/256-column/131072-byte guards, and owns a five-second inactivity + timer. Failed transfers and malformed blocks reset before leaving the current + line readable. +- 2026-09-07: Connected capture reset to GUI refresh, reconnect, profile reset, + package reload, cleanup, and uninstall paths, including retirement of a + legacy `map.maplineTrig`. The resource audit now reports 36 owned anonymous + handlers, 22 owned timer creation sites, two package-XML handlers, and zero + unowned handlers or timers. +- 2026-09-07: Added and registered eight production-source regression groups. + They cover normal spacing, the exact `145202` fixture with formatting, room + heights 3/9/11/13/25, true blank rows, a 21-row wilderness map, multiple and + nested blocks, mismatches/timeouts/safety limits, destination failures, next- + map recovery, and lifecycle cleanup. Existing lifecycle coverage now resets + an active capture and owned timeout during cleanup/uninstall. +- 2026-09-07: Production-trigger native replays pass on official Mudlet 4.21.0 + and 4.22.0 builds and on a version-recorded 5.0.1 comparison. A complete + generated 2.0.4.046 profile also passed over Mudlet 4.22.0's actual Telnet + path against the local server: both compact settings captured all nine + `145202` rows with no map leakage, while compact-off preserved the additional + server blank line. The character was independently verified back in room + `1204` with GUI off, compact off, automap on, brief off, and fully logged out. +- 2026-09-07: Final gates passed: build validation, generated-output parity, + all nine suites supported by the installed tools, standalone package + validation, handler/timer ownership analysis, and whitespace/error checking. + `luacheck` was not installed, so the documented `--skip-optional` path ran all + available suites with `lua` and `luac`; the optional quality suite remains a + hosted-CI check. ## Evidence and reproduction @@ -47,8 +89,11 @@ package version, or server source was changed during this investigation. lines in an initial trial; those results were excluded. - Read main-console and miniconsole buffers with `getLines()` and recorded every map-row callback. A complete block and a replay split at each newline - produced the same first-row failure. Arbitrary TCP fragmentation and a - full-package live-Mudlet session remain implementation acceptance tests. + produced the same first-row failure. A full-package live-Mudlet session was + subsequently completed, and the native line-split replay and live Telnet + result agreed. Deliberate transport fragment boundaries were not observable; + the package parser receives Mudlet's completed logical lines and no longer + owns byte-stream assembly. - A comparison run reported Mudlet 5.0.1 and captured all nine rows. The disposable runtime's automatic updater had changed the executable between runs. Its result was kept separately; automatic downloads were then @@ -59,9 +104,10 @@ package version, or server source was changed during this investigation. those settings and completed character and account logout. Authentication data is excluded from this document and the replay fixtures. -The user's installed Mudlet version has not yet been established. The -version-specific reproduction above should be compared with that version -when implementing the fix. The supplied server checkout was at `2fa02cca6`; +The user's installed Mudlet version was not available to the automated test. +The supported 4.21/4.22 builds and a newer 5.0.1 comparison now agree, because +the implementation no longer creates a timing-sensitive trigger mid-stream. +The supplied server checkout was at `2fa02cca6`; the running listener used a binary under its `bin/releases/` directory, so checkout identity alone is not proof of the running binary's source revision. The live output independently confirms the relevant map framing and spacing. diff --git a/tests/run_tests.py b/tests/run_tests.py index d9d5396..ae4f548 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -25,6 +25,7 @@ from test_lifecycle_regressions import LifecycleRegressionTester from test_lua_quality import LuaQualityAnalyzer from test_lua_syntax import LuaSyntaxTester + from test_output_capture import OutputCaptureTester from test_performance import PerformanceTester from test_system import SystemTester except ImportError as e: @@ -149,6 +150,7 @@ def run_all_tests(self, parallel=True, skip_optional=False): ("Function Tests", LuaFunctionTester), ("Event System", EventSystemTester), ("Lifecycle Regressions", LifecycleRegressionTester), + ("Output Capture", OutputCaptureTester), ("System Tests", SystemTester), ("Performance", PerformanceTester), ] @@ -371,6 +373,7 @@ def run_single_test(self, test_name): "functions": ("Function Tests", LuaFunctionTester), "events": ("Event System", EventSystemTester), "lifecycle": ("Lifecycle Regressions", LifecycleRegressionTester), + "output": ("Output Capture", OutputCaptureTester), "system": ("System Tests", SystemTester), "performance": ("Performance", PerformanceTester), } @@ -481,7 +484,7 @@ def main(): ) parser.add_argument( "--test", - help="Run specific test suite (coverage, extractor, syntax, quality, functions, events, lifecycle, system, performance)", + help="Run specific test suite (coverage, extractor, syntax, quality, functions, events, lifecycle, output, system, performance)", ) parser.add_argument("--report", help="Generate report file") parser.add_argument( diff --git a/tests/test_lifecycle_regressions.py b/tests/test_lifecycle_regressions.py index 093e164..a77355c 100644 --- a/tests/test_lifecycle_regressions.py +++ b/tests/test_lifecycle_regressions.py @@ -421,6 +421,7 @@ def _test_package_cleanup_removes_owned_resources(self): self.resource_source_path, "Resource Ownership", ) + capture_source = self._gui_script("ASCII Map Capture") preferences_source = self._gui_script("Toggles") cleanup_source = preferences_source[ preferences_source.index("function GUI.cleanup()") : @@ -490,6 +491,7 @@ def _test_package_cleanup_removes_owned_resources(self): lifecycleHandlerIds = {{}}, }} {resource_source} +{capture_source} GUI.EVENT_HANDLERS = {{["msdp.ONE"] = true, ["msdp.TWO"] = true}} GUI.LIFECYCLE_HANDLERS = {{sysLoadEvent = true}} @@ -520,6 +522,8 @@ def _test_package_cleanup_removes_owned_resources(self): ) GUI.setOwnedTimer("cleanup.one", 1, function() end) GUI.setOwnedTimer("cleanup.two", 1, function() end) +GUI.AsciiMapCapture.state = {{kind = "room", rows = 1, bytes = 20}} +GUI.setOwnedTimer("asciiMapCapture.inactivity", 5, function() end) function GUI.unregisterEventHandlers() return GUI.unregisterOwnedHandlers(GUI.eventHandlerIds, GUI.EVENT_HANDLERS) @@ -554,6 +558,7 @@ def _test_package_cleanup_removes_owned_resources(self): assert(countEntries(map.fileScopeHandlerIds) == 0) assert(countEntries(GUI.lifecycleHandlerIds) == 0) assert(map.maplineTrig == nil) +assert(GUI.AsciiMapCapture.state == nil) assert(saves == 1) assert(blinkStops == 1) """ @@ -646,6 +651,11 @@ def _test_handler_counts_across_lifecycle_paths(self): }} {resource_source} +local captureResets = 0 +GUI.AsciiMapCapture = {{reset = function() + captureResets = captureResets + 1 +end}} + map = {{ eventHandler = function() end, onProtocolEnabled = function() end, @@ -706,13 +716,19 @@ def _test_handler_counts_across_lifecycle_paths(self): runAllTimers() assertStable("fresh sysLoadEvent") +local resetsBeforeReconnect = captureResets GUI.onConnectionEvent("sysConnectionEvent") runAllTimers() assertStable("reconnect") +assert(captureResets == resetsBeforeReconnect + 2, + "reconnect did not reset capture immediately and during refresh") +local resetsBeforeProfileReset = captureResets GUI.onSysLoadEvent("sysLoadEvent", false) runAllTimers() assertStable("resetProfile") +assert(captureResets == resetsBeforeProfileReset + 1, + "resetProfile refresh did not reset capture") for _ = 1, 10 do GUI.initializeOrRefresh("fix gui command") @@ -759,6 +775,10 @@ def _test_handler_analyzer_reports_owned_resources(self): totals["recurring_timers"] == 1, f"unexpected recurring timer total: {totals}", ) + self._require( + totals["owned_timers"] == 22, + f"unexpected owned timer site total: {totals}", + ) self._require( totals["unowned_handlers"] == 0 and totals["unowned_timers"] == 0, f"analyzer reported unowned resources: {totals}", @@ -1353,6 +1373,7 @@ def _test_gui_script_names_and_order(self): "Buttons", "Room Info/Legend", "DrawFrames", + "ASCII Map Capture", "MSDP Protocol", "MSDP Gauges", "MSDP Actions", @@ -1835,9 +1856,6 @@ def _test_debug_startup_boundary_and_system_coverage(self): yatco_source = "\n".join( path.read_text(encoding="utf-8") for path in yatco_sources ) - trigger_source = ( - self.repo_root / "theGUI" / "src" / "triggers" / "01_gui.xml" - ).read_text(encoding="utf-8") alias_source = "\n".join( path.read_text(encoding="utf-8") for path in (self.repo_root / "theGUI" / "src" / "aliases").glob("*.xml") @@ -1876,7 +1894,7 @@ def _test_debug_startup_boundary_and_system_coverage(self): "sound subsystem": (gui_source, "SOUND/PLAY"), "mapper events": (mapper_source, "MAPPER/EVENT"), "mapper initialization": (mapper_source, "MAPPER/INIT"), - "map triggers": (trigger_source, "TRIGGER/MAP"), + "map capture": (gui_source, "ASCII_MAP_CAPTURE"), "chat creation": (yatco_source, "YATCO/CREATE"), "chat capture": (yatco_source, "YATCO/APPEND"), "aliases": (alias_source, 'GUI.debug("ALIAS"'), diff --git a/tests/test_output_capture.py b/tests/test_output_capture.py new file mode 100644 index 0000000..afff907 --- /dev/null +++ b/tests/test_output_capture.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Regression tests for boundary-driven ASCII map capture and output spacing.""" + +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class OutputCaptureTester: + def __init__(self, _xml_file=None): + self.repo_root = PROJECT_ROOT + self.capture_path = ( + self.repo_root + / "theGUI" + / "src" + / "scripts" + / "gui" + / "37_ascii_map_capture.xml" + ) + self.trigger_path = ( + self.repo_root / "theGUI" / "src" / "triggers" / "01_gui.xml" + ) + self.lua_path = self._find_lua() + self.test_results = [] + self.errors = [] + self.warnings = [] + self.capture_source = self._capture_source() + + @staticmethod + def _find_lua(): + for executable in ("lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"): + path = shutil.which(executable) + if path: + return path + return None + + def _capture_source(self): + fragment = ET.fromstring( + "" + self.capture_path.read_text(encoding="utf-8") + "" + ) + scripts = fragment.findall(".//Script") + if len(scripts) != 1 or scripts[0].findtext("name") != "ASCII Map Capture": + raise AssertionError("ASCII map capture fragment has unexpected topology") + return scripts[0].findtext("script") or "" + + @staticmethod + def _mocks(): + return r''' +destination = {} +destinationStyles = {} +main = {} +deletedLines = {} +debugEvents = {} +fitCalls = {} +timers = {} +activeTriggers = {[77] = true} +nextTimerId = 100 +currentLine = nil +currentStyle = nil +copiedLine = nil +copiedStyle = nil +pendingPrefix = "" +failAppend = false +throwAppend = false +failClear = false +failDelete = false +clearCount = 0 + +local function container() + return { + shown = 0, + hidden = 0, + show = function(self) self.shown = self.shown + 1 end, + hide = function(self) self.hidden = self.hidden + 1 end, + } +end + +GUI = { + buttonWindow = {mudletOrAscii = "ASCII"}, + asciiMapContainer = container(), + debug = function(scope, message, detail) + debugEvents[#debugEvents + 1] = {scope = scope, message = message, detail = detail} + end, + debugError = function(scope, message) + debugEvents[#debugEvents + 1] = {scope = scope, message = message, error = true} + end, +} + +function GUI.cancelOwnedTimer(name) + local present = timers[name] ~= nil + timers[name] = nil + return present +end + +function GUI.setOwnedTimer(name, delay, callback) + GUI.cancelOwnedTimer(name) + nextTimerId = nextTimerId + 1 + timers[name] = {id = nextTimerId, delay = delay, callback = callback} + return nextTimerId +end + +function fireTimer(name) + local timer = timers[name] + assert(timer, "timer does not exist: " .. tostring(name)) + timers[name] = nil + timer.callback() +end + +map = { + maplineTrig = 77, + container = container(), + minimap = {}, + calcMinimapPadding = function() return 2.9 end, + adjustAsciimapFontSize = function(columns, rows) + fitCalls[#fitCalls + 1] = {kind = "room", columns = columns, rows = rows} + end, + adjustMinimapFontSize = function(columns, rows) + fitCalls[#fitCalls + 1] = {kind = "wilderness", columns = columns, rows = rows} + end, +} + +function map.minimap:echo(value) + if value:sub(-1) == "\n" then + destination[#destination + 1] = pendingPrefix .. value:sub(1, -2) + destinationStyles[#destinationStyles + 1] = nil + pendingPrefix = "" + else + pendingPrefix = pendingPrefix .. value + end +end + +function exists(itemId, itemType) + if itemType == "trigger" and activeTriggers[itemId] then return 1 end + return 0 +end + +function killTrigger(itemId) + if not activeTriggers[itemId] then return false end + activeTriggers[itemId] = nil + return true +end + +function clearUserWindow(name) + assert(name == "map.minimap") + if failClear then return false end + destination = {} + destinationStyles = {} + pendingPrefix = "" + clearCount = clearCount + 1 +end + +function selectCurrentLine() + assert(currentLine ~= nil) +end + +function copy() + copiedLine = currentLine + copiedStyle = currentStyle +end + +function appendBuffer(name) + assert(name == "map.minimap") + if throwAppend then error("append failure") end + if failAppend then return false end + destination[#destination + 1] = pendingPrefix .. copiedLine + destinationStyles[#destinationStyles + 1] = copiedStyle + pendingPrefix = "" +end + +function deleteLine() + if failDelete then error("delete failure") end + deletedLines[#deletedLines + 1] = currentLine +end + +function feedLine(text, style) + currentLine = text + currentStyle = style + local deletedBefore = #deletedLines + local action = GUI.AsciiMapCapture.processLine(text) + if #deletedLines == deletedBefore then + main[#main + 1] = {text = text, style = style} + end + return action +end + +function repeatedRow(width, symbol) + assert(#symbol == 1) + return symbol .. string.rep(" ", width - 1) +end +''' + + def _run_lua(self, body): + source = self._mocks() + "\n" + self.capture_source + "\n" + body + result = subprocess.run( + [self.lua_path, "-"], + input=source, + cwd=self.repo_root, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise AssertionError((result.stderr or result.stdout).strip()) + + def _test_permanent_trigger_and_spacing_contract(self): + root = ET.fromstring( + "" + self.trigger_path.read_text(encoding="utf-8") + "" + ) + gui = next( + node + for node in root.findall("./TriggerGroup") + if node.findtext("name") == "GUI" + ) + triggers = gui.findall("./Trigger") + capture = [node for node in triggers if node.findtext("name") == "Capture ASCII Maps"] + if len(capture) != 1: + raise AssertionError("exactly one permanent ASCII map trigger is required") + if capture[0].findtext("regexCodeList/string") != "^.*$": + raise AssertionError("ASCII map trigger does not dispatch every logical line") + if capture[0].get("isTempTrigger") != "no": + raise AssertionError("ASCII map dispatcher unexpectedly became temporary") + + source = self.trigger_path.read_text(encoding="utf-8") + forbidden = ( + "Gag blank lines", + "Capture Room Map", + "Capture Wilderness Map", + "tempLineTrigger(1,11", + "tempLineTrigger(1,23", + "function onRoomMapLine", + "function onMapLine", + ) + returned = [value for value in forbidden if value in source] + if returned: + raise AssertionError("obsolete output triggers returned: " + ", ".join(returned)) + + self._run_lua( + r''' +assert(map.maplineTrig == nil, "legacy line trigger ID survived parser load") +assert(activeTriggers[77] == nil, "legacy line trigger survived parser load") +assert(feedLine("ordinary output") == "ignored") +assert(feedLine("") == "ignored") +assert(feedLine(" indented words ") == "ignored") +assert(#deletedLines == 0, "ordinary output was deleted") +assert(#main == 3 and main[2].text == "" and main[3].text == " indented words ") +''' + ) + + def _test_exact_room_fixture_and_formatting(self): + self._run_lua( + r''' +local rows = { + " [.]-[|]-[.] ", + " | ", + " [.]-[C]-[.] [.]", + " | | ", + " [.]-[&]-[.] [Y]", + " | | ", + " [.]-[C]-[,]-[Y]", + " | | ", + "[-]-[-]-[,]-[,]-[.]", +} + +assert(feedLine("before") == "ignored") +assert(feedLine("") == "started") +for index, row in ipairs(rows) do + assert(feedLine(row, "ansi-" .. index) == "row") +end +assert(feedLine("") == "finished") +assert(feedLine("") == "ignored") +assert(feedLine("description with spaces") == "ignored") +assert(feedLine("Prompt> ") == "ignored") + +assert(#destination == #rows, "room map row count changed") +for index, row in ipairs(rows) do + assert(destination[index] == " " .. row, "room row changed at " .. index) + assert(destinationStyles[index] == "ansi-" .. index, "ANSI style was not copied") +end +assert(#main == 4, "map content leaked into the main console") +assert(main[1].text == "before" and main[2].text == "") +assert(main[3].text == "description with spaces" and main[4].text == "Prompt> ") +assert(#fitCalls == 1 and fitCalls[1].kind == "room") +assert(fitCalls[1].columns == 20 and fitCalls[1].rows == 9) +assert(GUI.AsciiMapCapture.state == nil) +assert(timers["asciiMapCapture.inactivity"] == nil) +''' + ) + + def _test_supported_heights_and_blank_rows(self): + self._run_lua( + r''' +for _, height in ipairs({3, 9, 11, 13, 25}) do + local width = (height * 2) + 1 + assert(feedLine("") == "started") + for row = 1, height do + assert(feedLine(repeatedRow(width, row % 2 == 0 and "|" or ".")) == "row") + end + assert(feedLine("") == "finished") + assert(#destination == height, "height was truncated: " .. height) + assert(destination[1] == " " .. repeatedRow(width, ".")) + local finalSymbol = height % 2 == 0 and "|" or "." + assert(destination[height] == " " .. repeatedRow(width, finalSymbol)) + local fit = fitCalls[#fitCalls] + assert(fit.kind == "room" and fit.columns == width + 1 and fit.rows == height) +end + +assert(feedLine("") == "started") +assert(feedLine("") == "row") +assert(feedLine(". ") == "row") +assert(feedLine("") == "row") +assert(feedLine("") == "finished") +assert(#destination == 3) +assert(destination[1] == " " and destination[2] == " . " and destination[3] == " ") +assert(fitCalls[#fitCalls].rows == 3 and fitCalls[#fitCalls].columns == 8) +''' + ) + + def _test_wilderness_and_multiple_blocks(self): + self._run_lua( + r''' +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(feedLine("") == "started", "new opener did not replace capture") +for row = 1, 21 do + assert(feedLine(repeatedRow(21, row == 11 and "*" or "~")) == "row") +end +assert(feedLine("") == "finished") +assert(#destination == 21 and destination[1] == " " .. repeatedRow(21, "~")) +assert(destination[11] == " " .. repeatedRow(21, "*")) +assert(destination[21] == " " .. repeatedRow(21, "~")) +assert(clearCount == 2, "each opening marker must clear exactly once") +assert(#fitCalls == 1 and fitCalls[1].kind == "wilderness") +assert(fitCalls[1].columns == 23 and fitCalls[1].rows == 21) +assert(#main == 0, "successful maps leaked into main output") +''' + ) + + def _test_invalid_and_mismatched_recovery(self): + self._run_lua( + r''' +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(feedLine("This is ordinary prose.") == "aborted") +assert(main[#main].text == "This is ordinary prose.") +assert(feedLine("Prompt> ") == "ignored") +assert(main[#main].text == "Prompt> ") +assert(GUI.AsciiMapCapture.state == nil) + +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(feedLine("") == "aborted") +assert(main[#main].text == "") +assert(feedLine("after mismatch") == "ignored") + +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(feedLine(repeatedRow(18, ".")) == "aborted") +assert(main[#main].text == repeatedRow(18, ".")) + +assert(feedLine("") == "started") +fireTimer("asciiMapCapture.inactivity") +assert(GUI.AsciiMapCapture.state == nil) +assert(feedLine("after timeout") == "ignored") +assert(main[#main].text == "after timeout") +''' + ) + + def _test_destination_failures_preserve_current_line(self): + self._run_lua( + r''' +map.minimap = nil +assert(feedLine("") == "ignored") +assert(main[#main].text == "", "missing destination consumed opener") + +map.minimap = {echo = function(_, value) + if value:sub(-1) ~= "\n" then pendingPrefix = pendingPrefix .. value end +end} +assert(feedLine("") == "started") +failAppend = true +assert(feedLine(repeatedRow(19, ".")) == "aborted") +assert(main[#main].text == repeatedRow(19, "."), "append failure consumed row") +assert(GUI.AsciiMapCapture.state == nil) + +failAppend = false +failClear = true +assert(feedLine("") == "ignored") +assert(main[#main].text == "", "clear failure consumed opener") +''' + ) + + def _test_safety_limit_and_next_map_recovery(self): + self._run_lua( + r''' +local row = repeatedRow(19, ".") +assert(feedLine("") == "started") +for _ = 1, 64 do assert(feedLine(row) == "row") end +assert(feedLine(row) == "aborted") +assert(main[#main].text == row, "oversized row was consumed") +assert(feedLine("ordinary after limit") == "ignored") +assert(main[#main].text == "ordinary after limit") + +assert(feedLine("") == "started") +assert(feedLine(row) == "row") +assert(feedLine("") == "finished") +assert(#destination == 1 and destination[1] == " " .. row) +''' + ) + + def _test_lifecycle_reset_hooks(self): + sources = { + "cleanup": self.repo_root + / "theGUI" + / "src" + / "scripts" + / "gui" + / "01_preferences.xml", + "refresh": self.repo_root + / "theGUI" + / "src" + / "scripts" + / "gui" + / "52_refresh.xml", + "connection": self.repo_root + / "theGUI" + / "src" + / "scripts" + / "gui" + / "53_lifecycle.xml", + } + for boundary, path in sources.items(): + source = path.read_text(encoding="utf-8") + if "GUI.AsciiMapCapture.reset" not in source: + raise AssertionError(f"{boundary} does not reset active map capture") + + self._run_lua( + r''' +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(GUI.AsciiMapCapture.reset("test lifecycle boundary") == true) +assert(GUI.AsciiMapCapture.state == nil) +assert(timers["asciiMapCapture.inactivity"] == nil) +assert(feedLine("ordinary after lifecycle reset") == "ignored") +assert(main[#main].text == "ordinary after lifecycle reset") +''' + ) + + def run_tests(self): + print("Running output capture regression tests...") + if not self.lua_path: + self.errors.append("lua interpreter not found in PATH") + print(" ✗ Lua is required for output capture regression tests") + return False + + tests = [ + ("permanent_trigger_and_spacing", self._test_permanent_trigger_and_spacing_contract), + ("exact_room_fixture_and_formatting", self._test_exact_room_fixture_and_formatting), + ("supported_heights_and_blank_rows", self._test_supported_heights_and_blank_rows), + ("wilderness_and_multiple_blocks", self._test_wilderness_and_multiple_blocks), + ("invalid_and_mismatched_recovery", self._test_invalid_and_mismatched_recovery), + ("destination_failure_recovery", self._test_destination_failures_preserve_current_line), + ("safety_limit_and_next_map", self._test_safety_limit_and_next_map_recovery), + ("lifecycle_reset_hooks", self._test_lifecycle_reset_hooks), + ] + for name, test in tests: + try: + test() + self.test_results.append({"name": name, "success": True}) + print(f" ✓ {name}") + except Exception as error: + message = f"{name}: {error}" + self.errors.append(message) + self.test_results.append( + {"name": name, "success": False, "error": str(error)} + ) + print(f" ✗ {message}") + + passed = sum(result["success"] for result in self.test_results) + print(f"Output capture results: {passed}/{len(tests)} passed") + return passed == len(tests) + + def get_results(self): + return { + "test_results": self.test_results, + "errors": self.errors, + "warnings": self.warnings, + } + + +def main(): + tester = OutputCaptureTester() + success = tester.run_tests() + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/theGUI/build.yaml b/theGUI/build.yaml index 98c6ba5..70df8c7 100644 --- a/theGUI/build.yaml +++ b/theGUI/build.yaml @@ -5,7 +5,7 @@ package: name: "LuminariGUI" - version: "2.0.4.045" + version: "2.0.4.046" output: file: "../LuminariGUI.xml" diff --git a/theGUI/src/scripts/00_msdpmapper.xml b/theGUI/src/scripts/00_msdpmapper.xml index da0e444..c7ea384 100644 --- a/theGUI/src/scripts/00_msdpmapper.xml +++ b/theGUI/src/scripts/00_msdpmapper.xml @@ -277,29 +277,29 @@ function map.load_map(use_local) end end -function map.adjustMinimapFontSize() +function map.adjustMinimapFontSize(columns, rows) local w = map.minimap.get_width() local h = map.minimap.get_height() local font_size = 8 repeat font_size = font_size + 1 local width, height = calcFontSize(font_size) - width = width * map.minimap_width - height = height * map.minimap_height + width = width * (columns or map.minimap_width) + height = height * (rows or map.minimap_height) until (w < width) or (h < height) map.minimap_font_size = font_size - 1 setMiniConsoleFontSize("map.minimap", map.minimap_font_size) end -function map.adjustAsciimapFontSize() +function map.adjustAsciimapFontSize(columns, rows) local w = map.minimap.get_width() local h = map.minimap.get_height() local font_size = 8 repeat font_size = font_size + 1 local width, height = calcFontSize(font_size) - width = width * 20 - height = height * 11 + width = width * (columns or 20) + height = height * (rows or 11) until (w < width) or (h < height) map.minimap_font_size = font_size - 1 setMiniConsoleFontSize("map.minimap", map.minimap_font_size) diff --git a/theGUI/src/scripts/01_gui.xml b/theGUI/src/scripts/01_gui.xml index 8e8684d..f096941 100644 --- a/theGUI/src/scripts/01_gui.xml +++ b/theGUI/src/scripts/01_gui.xml @@ -68,6 +68,7 @@ GUI.debug("GUI/BOOT", "GUI namespace fragment loaded", { + diff --git a/theGUI/src/scripts/gui/01_preferences.xml b/theGUI/src/scripts/gui/01_preferences.xml index 4542190..b319258 100644 --- a/theGUI/src/scripts/gui/01_preferences.xml +++ b/theGUI/src/scripts/gui/01_preferences.xml @@ -149,19 +149,21 @@ function GUI.cleanup() if demonnic and demonnic.chat and demonnic.chat.stopBlinking then demonnic.chat:stopBlinking() end - local timersCanceled = GUI.cancelAllOwnedTimers() - GUI.castConsoleTimer = nil - GUI.initCallPending = false - GUI.msdpReportRequestPending = false - GUI.refreshCallsPending = {} - - if map and map.maplineTrig then + if GUI.AsciiMapCapture and type(GUI.AsciiMapCapture.reset) == "function" then + pcall(GUI.AsciiMapCapture.reset, "GUI cleanup") + elseif map and map.maplineTrig then if exists(map.maplineTrig, "trigger") ~= 0 then pcall(killTrigger, map.maplineTrig) end map.maplineTrig = nil end + local timersCanceled = GUI.cancelAllOwnedTimers() + GUI.castConsoleTimer = nil + GUI.initCallPending = false + GUI.msdpReportRequestPending = false + GUI.refreshCallsPending = {} + GUI.saveToggles() return {timers = timersCanceled} end diff --git a/theGUI/src/scripts/gui/37_ascii_map_capture.xml b/theGUI/src/scripts/gui/37_ascii_map_capture.xml new file mode 100644 index 0000000..507758b --- /dev/null +++ b/theGUI/src/scripts/gui/37_ascii_map_capture.xml @@ -0,0 +1,301 @@ + + + diff --git a/theGUI/src/scripts/gui/52_refresh.xml b/theGUI/src/scripts/gui/52_refresh.xml index 39812aa..e59ac51 100644 --- a/theGUI/src/scripts/gui/52_refresh.xml +++ b/theGUI/src/scripts/gui/52_refresh.xml @@ -23,6 +23,9 @@ -- ============================================================================= function GUI.initializeOrRefresh(context) context = context or "unknown" + if GUI.AsciiMapCapture and type(GUI.AsciiMapCapture.reset) == "function" then + GUI.AsciiMapCapture.reset("GUI refresh: " .. context) + end -- Coalesce matching callbacks from the legacy anonymous lifecycle handler -- and its replacement during a live package upgrade. Calls with different -- contexts still run independently (for example protocol enablement after diff --git a/theGUI/src/scripts/gui/53_lifecycle.xml b/theGUI/src/scripts/gui/53_lifecycle.xml index 06f81b4..0b24d98 100644 --- a/theGUI/src/scripts/gui/53_lifecycle.xml +++ b/theGUI/src/scripts/gui/53_lifecycle.xml @@ -96,6 +96,9 @@ end function GUI.onConnectionEvent(...) GUI.debug("LIFECYCLE", "sysConnectionEvent fired", {...}) + if GUI.AsciiMapCapture and type(GUI.AsciiMapCapture.reset) == "function" then + GUI.AsciiMapCapture.reset("connection established") + end GUI.setOwnedTimer("lifecycle.connectionRefresh", 1, GUI.debugWrap("LIFECYCLE/connection refresh timer", function() GUI.initializeOrRefresh("connection established") if type(GUI.debugSnapshot) == "function" then diff --git a/theGUI/src/triggers/01_gui.xml b/theGUI/src/triggers/01_gui.xml index 6dff1be..51c61e2 100644 --- a/theGUI/src/triggers/01_gui.xml +++ b/theGUI/src/triggers/01_gui.xml @@ -14,66 +14,9 @@ - Capture Wilderness Map - 0 0 @@ -86,110 +29,7 @@ end #000000 #000000 - <WILDERNESS_MAP> - - - 2 - - - - Capture Room Map - - 0 - 0 - 0 - - - #ff0000 - #ffff00 - - #000000 - #000000 - - <ROOM_MAP> - - - 2 - - - - Gag blank lines - - 0 - 0 - 0 - - - #ff0000 - #ffff00 - - #000000 - #000000 - - ^$ + ^.*$ 1 From 30815106b813d0826899908ca4e78256a4717e5d Mon Sep 17 00:00:00 2001 From: moshehbenavraham Date: Mon, 7 Sep 2026 14:00:19 +0300 Subject: [PATCH 3/5] chore: add repository agent workflow skills --- .agents/skills/plan-ablation/SKILL.md | 23 ++++++++++++++ .agents/skills/scopeguard/SKILL.md | 32 ++++++++++++++++++++ .agents/skills/scopeguard/agents/openai.yaml | 4 +++ docs/ongoing-projects/.gitkeep | 0 4 files changed, 59 insertions(+) create mode 100644 .agents/skills/plan-ablation/SKILL.md create mode 100644 .agents/skills/scopeguard/SKILL.md create mode 100644 .agents/skills/scopeguard/agents/openai.yaml create mode 100644 docs/ongoing-projects/.gitkeep diff --git a/.agents/skills/plan-ablation/SKILL.md b/.agents/skills/plan-ablation/SKILL.md new file mode 100644 index 0000000..ff45201 --- /dev/null +++ b/.agents/skills/plan-ablation/SKILL.md @@ -0,0 +1,23 @@ +--- +name: plan-ablation +description: Simplify a coding plan by checking what can be removed while still meeting the task's requirements. Use after planning and before implementation. +--- + +# Plan ablation + +Review the proposed plan before editing code. Ablation means mentally removing a planned part +and checking what would fail; it does not require implementing multiple versions. + +Check the requested outcome and acceptance criteria before removing work. Do not simplify by +silently dropping required behavior. State any scope assumption that materially changes the result. + +- For each meaningful change, ask: if we omit this, which requirement or concrete correctness + risk goes unmet? Remove it if there is no concrete answer. +- Look for a simpler solution using existing code and patterns. Question new abstractions, + dependencies, configuration, fallback paths, and work justified only by hypothetical future use. +- Keep changes and checks needed for the requested behavior, safety, compatibility, and + repository requirements. Fewer lines alone do not make a solution better. + +Briefly state what to remove or simplify and why, then update the plan before proceeding. +If nothing can be removed, say why the plan is already minimal. A sentence is enough for a +small task; do not create a separate report or add an approval step. diff --git a/.agents/skills/scopeguard/SKILL.md b/.agents/skills/scopeguard/SKILL.md new file mode 100644 index 0000000..c9cf778 --- /dev/null +++ b/.agents/skills/scopeguard/SKILL.md @@ -0,0 +1,32 @@ +--- +name: scopeguard +description: >- + Keep an implementation within its agreed scope when a scope review is requested or work expands + into speculative design. +--- + +Complete only the current task within its agreed scope. + +Inspect the relevant code, tests, and config needed for the requested change; do not rely on +snippets, guesses, or unverified premises. Resolve uncertainties that affect correctness or scope. +For substantial work, briefly state: **Outcome, Non-goals, Files, and Proof**; use plan-ablation +without duplicating it. Use one implementation path unless parts are truly independent. + +Reuse existing code, helpers, patterns, and tests. Fix root causes; preserve unrelated behavior; avoid speculative/future design; add abstractions, adapters, or config only for a second real caller or explicit requirement. Remove replaced code and retain old paths only for required compatibility. + +Read-only discovery is allowed. Continue with local edits and tests already authorized by the task, +including requested API or schema changes. Ask before expanding into unrelated dependencies, +frameworks, services, test infrastructure, or duplicate implementations, or before a consequential +scope decision or action not already authorized. Obtain explicit authorization before destructive +data operations, production mutation, discarding user work, rewriting history, or dropping data. + +Run the narrowest relevant existing tests and extend existing tests before creating new files. Add tests only for requested or uncovered changed user-observable behavior, with each test protecting a clear acceptance criterion or regression risk. Do not add unrelated coverage or use passing tests to justify extra scope. + +If the work grows into future-use layers, workaround stacks, unrelated cleanup, or unstated tests, +remove unnecessary work and keep the requested behavior. Ask only for consequential scope expansion. + +Done means the requested behavior and acceptance criteria pass; exact commands/results are reported; +every touched file is necessary; the diff contains nothing unrelated; no task-created debug or +scratch artifacts remain in the deliverable; required recovery evidence and unrelated work are +preserved and reported; and assumptions, limitations, and unverified runtime behavior are stated +plainly. diff --git a/.agents/skills/scopeguard/agents/openai.yaml b/.agents/skills/scopeguard/agents/openai.yaml new file mode 100644 index 0000000..7d753ed --- /dev/null +++ b/.agents/skills/scopeguard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Scope Guard" + short_description: "Keep work within agreed scope" + default_prompt: "Use $scopeguard when scope is expanding or a scope review is needed." diff --git a/docs/ongoing-projects/.gitkeep b/docs/ongoing-projects/.gitkeep new file mode 100644 index 0000000..e69de29 From 91ce6ed54df0fe9349f74bad0f487e816b3dad56 Mon Sep 17 00:00:00 2001 From: moshehbenavraham Date: Mon, 7 Sep 2026 14:04:13 +0300 Subject: [PATCH 4/5] style: format output capture regression test --- tests/test_output_capture.py | 84 +++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/tests/test_output_capture.py b/tests/test_output_capture.py index afff907..5f4a915 100644 --- a/tests/test_output_capture.py +++ b/tests/test_output_capture.py @@ -7,7 +7,6 @@ import xml.etree.ElementTree as ET from pathlib import Path - PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -50,7 +49,7 @@ def _capture_source(self): @staticmethod def _mocks(): - return r''' + return r""" destination = {} destinationStyles = {} main = {} @@ -192,7 +191,7 @@ def _mocks(): assert(#symbol == 1) return symbol .. string.rep(" ", width - 1) end -''' +""" def _run_lua(self, body): source = self._mocks() + "\n" + self.capture_source + "\n" + body @@ -217,11 +216,15 @@ def _test_permanent_trigger_and_spacing_contract(self): if node.findtext("name") == "GUI" ) triggers = gui.findall("./Trigger") - capture = [node for node in triggers if node.findtext("name") == "Capture ASCII Maps"] + capture = [ + node for node in triggers if node.findtext("name") == "Capture ASCII Maps" + ] if len(capture) != 1: raise AssertionError("exactly one permanent ASCII map trigger is required") if capture[0].findtext("regexCodeList/string") != "^.*$": - raise AssertionError("ASCII map trigger does not dispatch every logical line") + raise AssertionError( + "ASCII map trigger does not dispatch every logical line" + ) if capture[0].get("isTempTrigger") != "no": raise AssertionError("ASCII map dispatcher unexpectedly became temporary") @@ -237,10 +240,12 @@ def _test_permanent_trigger_and_spacing_contract(self): ) returned = [value for value in forbidden if value in source] if returned: - raise AssertionError("obsolete output triggers returned: " + ", ".join(returned)) + raise AssertionError( + "obsolete output triggers returned: " + ", ".join(returned) + ) self._run_lua( - r''' + r""" assert(map.maplineTrig == nil, "legacy line trigger ID survived parser load") assert(activeTriggers[77] == nil, "legacy line trigger survived parser load") assert(feedLine("ordinary output") == "ignored") @@ -248,12 +253,12 @@ def _test_permanent_trigger_and_spacing_contract(self): assert(feedLine(" indented words ") == "ignored") assert(#deletedLines == 0, "ordinary output was deleted") assert(#main == 3 and main[2].text == "" and main[3].text == " indented words ") -''' +""" ) def _test_exact_room_fixture_and_formatting(self): self._run_lua( - r''' + r""" local rows = { " [.]-[|]-[.] ", " | ", @@ -288,12 +293,12 @@ def _test_exact_room_fixture_and_formatting(self): assert(fitCalls[1].columns == 20 and fitCalls[1].rows == 9) assert(GUI.AsciiMapCapture.state == nil) assert(timers["asciiMapCapture.inactivity"] == nil) -''' +""" ) def _test_supported_heights_and_blank_rows(self): self._run_lua( - r''' + r""" for _, height in ipairs({3, 9, 11, 13, 25}) do local width = (height * 2) + 1 assert(feedLine("") == "started") @@ -317,12 +322,12 @@ def _test_supported_heights_and_blank_rows(self): assert(#destination == 3) assert(destination[1] == " " and destination[2] == " . " and destination[3] == " ") assert(fitCalls[#fitCalls].rows == 3 and fitCalls[#fitCalls].columns == 8) -''' +""" ) def _test_wilderness_and_multiple_blocks(self): self._run_lua( - r''' + r""" assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") assert(feedLine("") == "started", "new opener did not replace capture") @@ -337,12 +342,12 @@ def _test_wilderness_and_multiple_blocks(self): assert(#fitCalls == 1 and fitCalls[1].kind == "wilderness") assert(fitCalls[1].columns == 23 and fitCalls[1].rows == 21) assert(#main == 0, "successful maps leaked into main output") -''' +""" ) def _test_invalid_and_mismatched_recovery(self): self._run_lua( - r''' + r""" assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") assert(feedLine("This is ordinary prose.") == "aborted") @@ -367,12 +372,12 @@ def _test_invalid_and_mismatched_recovery(self): assert(GUI.AsciiMapCapture.state == nil) assert(feedLine("after timeout") == "ignored") assert(main[#main].text == "after timeout") -''' +""" ) def _test_destination_failures_preserve_current_line(self): self._run_lua( - r''' + r""" map.minimap = nil assert(feedLine("") == "ignored") assert(main[#main].text == "", "missing destination consumed opener") @@ -390,12 +395,12 @@ def _test_destination_failures_preserve_current_line(self): failClear = true assert(feedLine("") == "ignored") assert(main[#main].text == "", "clear failure consumed opener") -''' +""" ) def _test_safety_limit_and_next_map_recovery(self): self._run_lua( - r''' + r""" local row = repeatedRow(19, ".") assert(feedLine("") == "started") for _ = 1, 64 do assert(feedLine(row) == "row") end @@ -408,7 +413,7 @@ def _test_safety_limit_and_next_map_recovery(self): assert(feedLine(row) == "row") assert(feedLine("") == "finished") assert(#destination == 1 and destination[1] == " " .. row) -''' +""" ) def _test_lifecycle_reset_hooks(self): @@ -438,7 +443,7 @@ def _test_lifecycle_reset_hooks(self): raise AssertionError(f"{boundary} does not reset active map capture") self._run_lua( - r''' + r""" assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") assert(GUI.AsciiMapCapture.reset("test lifecycle boundary") == true) @@ -446,7 +451,7 @@ def _test_lifecycle_reset_hooks(self): assert(timers["asciiMapCapture.inactivity"] == nil) assert(feedLine("ordinary after lifecycle reset") == "ignored") assert(main[#main].text == "ordinary after lifecycle reset") -''' +""" ) def run_tests(self): @@ -457,13 +462,34 @@ def run_tests(self): return False tests = [ - ("permanent_trigger_and_spacing", self._test_permanent_trigger_and_spacing_contract), - ("exact_room_fixture_and_formatting", self._test_exact_room_fixture_and_formatting), - ("supported_heights_and_blank_rows", self._test_supported_heights_and_blank_rows), - ("wilderness_and_multiple_blocks", self._test_wilderness_and_multiple_blocks), - ("invalid_and_mismatched_recovery", self._test_invalid_and_mismatched_recovery), - ("destination_failure_recovery", self._test_destination_failures_preserve_current_line), - ("safety_limit_and_next_map", self._test_safety_limit_and_next_map_recovery), + ( + "permanent_trigger_and_spacing", + self._test_permanent_trigger_and_spacing_contract, + ), + ( + "exact_room_fixture_and_formatting", + self._test_exact_room_fixture_and_formatting, + ), + ( + "supported_heights_and_blank_rows", + self._test_supported_heights_and_blank_rows, + ), + ( + "wilderness_and_multiple_blocks", + self._test_wilderness_and_multiple_blocks, + ), + ( + "invalid_and_mismatched_recovery", + self._test_invalid_and_mismatched_recovery, + ), + ( + "destination_failure_recovery", + self._test_destination_failures_preserve_current_line, + ), + ( + "safety_limit_and_next_map", + self._test_safety_limit_and_next_map_recovery, + ), ("lifecycle_reset_hooks", self._test_lifecycle_reset_hooks), ] for name, test in tests: From 02f766e913b4ed42710085c0135a30bfbc60470f Mon Sep 17 00:00:00 2001 From: moshehbenavraham Date: Mon, 7 Sep 2026 21:11:54 +0300 Subject: [PATCH 5/5] Address PR review findings --- LuminariGUI.xml | 17 +- coverage/baselines.json | 20 + docs/MUDLET_SMOKE_TEST.md | 2 +- docs/archive/LuminariGUI.xml_2.0.4.046 | 7180 +++++++++++++++++ docs/ongoing-projects/TASK_LIST.md | 17 +- tests/test_lifecycle_regressions.py | 5 + tests/test_output_capture.py | 63 +- theGUI/build.yaml | 2 +- .../src/scripts/gui/37_ascii_map_capture.xml | 15 +- 9 files changed, 7291 insertions(+), 30 deletions(-) create mode 100644 coverage/baselines.json create mode 100644 docs/archive/LuminariGUI.xml_2.0.4.046 diff --git a/LuminariGUI.xml b/LuminariGUI.xml index ea299e8..c151126 100644 --- a/LuminariGUI.xml +++ b/LuminariGUI.xml @@ -1,6 +1,6 @@ - + LuminariGUI @@ -3693,7 +3693,7 @@ local MAP_KINDS = { wilderness = { opening = "<WILDERNESS_MAP>", closing = "</WILDERNESS_MAP>", - allowed = allowedCharacters([[ [].,Yn^~=UX|-+M*CA:&S?#\/]]), + allowed = allowedCharacters([[ [].,Yn^m~=UX|-+oM*CAi:&S?#\/`]]), padding = function() if not map or type(map.calcMinimapPadding) ~= "function" then return 0 @@ -3850,9 +3850,6 @@ local function validRow(state, text) return false, "row contains a character that is invalid for " .. state.kind .. " maps" end end - if text ~= "" and state.width ~= 0 and #text ~= state.width then - return false, "row width changed inside map block" - end return true end @@ -3934,6 +3931,10 @@ function Capture.processLine(text) logError("could not consume closing marker: " .. tostring(deleteError)) return "aborted" end + if state.failed then + Capture.reset("failed map block closed") + return "aborted" + end finishCapture(state) return "finished" end @@ -3945,10 +3946,12 @@ function Capture.processLine(text) end end + if state.failed then return "ignored" end + local transferred, transferError = transferRow(state, text) if not transferred then - Capture.reset(transferError) - logError(transferError .. "; leaving current line visible") + state.failed = true + logError(tostring(transferError) .. "; leaving current line visible") return "aborted" end return "row" diff --git a/coverage/baselines.json b/coverage/baselines.json new file mode 100644 index 0000000..a71ae38 --- /dev/null +++ b/coverage/baselines.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "policy": "Informational history only; these values are not thresholds.", + "history": [ + { + "date": "2026-08-05", + "label": "Phase 4 initial local baseline", + "lua": { + "covered_lines": 860, + "num_statements": 2833, + "percent_covered": 30.36 + }, + "python": { + "covered_lines": 1489, + "num_statements": 2892, + "percent_covered": 51.49 + } + } + ] +} diff --git a/docs/MUDLET_SMOKE_TEST.md b/docs/MUDLET_SMOKE_TEST.md index 073bea1..e2cfcae 100644 --- a/docs/MUDLET_SMOKE_TEST.md +++ b/docs/MUDLET_SMOKE_TEST.md @@ -145,7 +145,7 @@ number-row events. Record the keyboard layout and platform with this section. 3. Disconnect and reconnect. Confirm one refresh, one MSDP subscription batch, stable maps, and stable callbacks. 4. Compare live handler/timer observations with - [`RESOURCE_LIFECYCLE.md`](RESOURCE_LIFECYCLE.md): 5 mapper, 26 GUI, and 6 + [`RESOURCE_LIFECYCLE.md`](RESOURCE_LIFECYCLE.md): 5 mapper, 25 GUI, and 6 lifecycle anonymous handlers; at most one recurring `yatco.blink` timer after one-shot work settles. diff --git a/docs/archive/LuminariGUI.xml_2.0.4.046 b/docs/archive/LuminariGUI.xml_2.0.4.046 new file mode 100644 index 0000000..ea299e8 --- /dev/null +++ b/docs/archive/LuminariGUI.xml_2.0.4.046 @@ -0,0 +1,7180 @@ + + + + + + LuminariGUI + + 0 + 0 + 0 + + LuminariGUI + #ff0000 + #ffff00 + + #000000 + #000000 + + + + YATCOConfig + + 0 + 0 + 0 + + YATCOConfig + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Tell + + 0 + 39 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) tells you, '(.*)' + You tell + + + 1 + 2 + + + + Congrats + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) congrats, '(.*) + You congrat, ' + + + 1 + 2 + + + + Chat + + 0 + 39 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) chats, '(.*) + You chat, ' + + + 1 + 2 + + + + Say + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You say, ' + You shout, ' + You holler, ' + You whisper to + You ask + You say out-of-character, ' + ^(\w+) says, ' + ^(\w+) shouts, ' + ^(\w+) hollers, ' + whispers to you, ' + asks you, ' + ^(\w+) says out-of-character, ' + + + 2 + 2 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 2 + 1 + + + + Auction + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^(\w+) auctalks, '(.*) + You auctalk, ' + + + 1 + 2 + + + + Group + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + [Group] + You group-say, ' + + + 2 + 2 + + + + Wiznet + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + [wiznet] + + + 2 + + + + + GUI + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Capture ASCII Maps + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^.*$ + + + 1 + + + + Cast Console + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + + + Started Cast + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + ^Casting\: (.+) (\*+)$ + + + 1 + + + + Cast Complete + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You complete your spell... + + + 2 + + + + Cast Aborted + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You abort your spell. + Your spell is aborted! + You are unable to find the target for your spell! + You are unable to find the object for your spell! + You are unable to continue your spell in your current position! + + + 3 + 3 + 3 + 3 + 3 + + + + Cast Canceled + + 0 + 0 + 0 + + + #ff0000 + #ffff00 + + #000000 + #000000 + + You are unable to continue casting! + You are too nauseated to continue casting! + + + 3 + 3 + + + + + + + + + + LuminariGUI + + + LuminariGUI + + + Toggles + + + + + + Gag Chat + + + + ^gag chat$ + + + Show Self + + + + ^show self$ + + + Horizontal Scroll + + + + ^hscroll$ + + + Sound + + + + ^sound(?:\s+(.*))?$ + + + + YATCO + + + YATCO + + + Demonnic + + + + + + Shared + + + + + + Reset chasing + + + + ^chaseres$ + + + Fix GUI Events + + + + ^fix gui$ + + + Debug + + + + ^debug(?: (list))?$ + + + debug categories + + + + ^debugc(?: (.*))?$ + + + + Tabbed Chat + + + + + + Toggle blinking (temporary change) + + + + ^dblink$ + + + dsound + + + + ^dsound$ + + + set chat sound + + + + ^set chat sound(?:\s+(.*))?$ + + + fixChat + + + + ^fix chat$ + + + + + + + + + + LuminariGUI + LuminariGUI + + + + LuminariDebug + LuminariGUI + + + + + + + + LuminariResources + LuminariGUI + + + + + + + + AdjustableContainersFoundation + LuminariGUI + + + + + + + + MSDPMapper + + + + + + + + + GUI + Template + + + + CSSman + CSSman + + + + + + + + GUI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + YATCOConfig + YATCOConfig + + + + YATCOCONFIG + + + + + + + + + + YATCO + YATCO + + + + Demonnic + + + + + Shared + + + + + + + + + + + + Tabbed Chat + + + + + + + + + sysLoadEvent + + + + + + + + sysInstall + + + + + + + LuminariDebugInstrumentation + LuminariGUI + + + + + + + + + + + Movement + + + + -1 + -1 + + Southwest + + + southwest + 49 + 536870912 + + + South + + + south + 50 + 536870912 + + + Southeast + + + southeast + 51 + 536870912 + + + West + + + west + 52 + 536870912 + + + Look + + + look + 53 + 536870912 + + + East + + + east + 54 + 536870912 + + + Northwest + + + northwest + 55 + 536870912 + + + North + + + north + 56 + 536870912 + + + Northeast + + + northeast + 57 + 536870912 + + + Inventory + + + inv + 47 + 536870912 + + + Scan + + + scan + 42 + 536870912 + + + Up + + + up + 45 + 536870912 + + + Down + + + down + 43 + 536870912 + + + + + + + diff --git a/docs/ongoing-projects/TASK_LIST.md b/docs/ongoing-projects/TASK_LIST.md index d457462..58aa738 100644 --- a/docs/ongoing-projects/TASK_LIST.md +++ b/docs/ongoing-projects/TASK_LIST.md @@ -162,13 +162,18 @@ in this folder. ## Feature backlog -- [ ] Restore normal output spacing and capture complete ASCII maps using +- [x] Restore normal output spacing and capture complete ASCII maps using [the investigation and fix plan](OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md). - - Investigated 2026-09-07: the global blank-line gag overrides compact mode; - native Mudlet 4.22.0 replay of local room `145202` captures 8 of 9 map - rows, with the first row leaking into the main console. Fixed capture - lengths also truncate taller maps. Implementation and regression coverage - remain pending. + - Completed 2026-09-07: removed the unconditional blank-line gag and replaced + fixed, version-sensitive line capture with a tagged boundary parser. + Regression coverage verifies complete room and wilderness maps, whitespace + and ANSI preservation, malformed-block recovery, safety limits, and + lifecycle cleanup. The focused Mudlet 4.22.0 smoke validated both compact + settings against local room `145202` but was not release approval. +- [ ] Complete release validation for output spacing and tagged ASCII maps. + - Run every section of [`MUDLET_SMOKE_TEST.md`](../MUDLET_SMOKE_TEST.md) for + the release candidate; treat the September 7 focused smoke as supporting + validation rather than a substitute for the full checklist. - [x] Expand sound support beyond chat notifications using a small native subsystem - Completed 2026-08-05: `GUI.Sound` centralizes tagged Mudlet media playback, safe profile/package file resolution, persistence, master/per-channel diff --git a/tests/test_lifecycle_regressions.py b/tests/test_lifecycle_regressions.py index a77355c..7a74c35 100644 --- a/tests/test_lifecycle_regressions.py +++ b/tests/test_lifecycle_regressions.py @@ -417,6 +417,7 @@ def _test_resource_ownership_manager(self): self._run_lua(script) def _test_package_cleanup_removes_owned_resources(self): + """Verify package cleanup removes active capture state and owned resources.""" resource_source = self._fragment_script( self.resource_source_path, "Resource Ownership", @@ -565,6 +566,7 @@ def _test_package_cleanup_removes_owned_resources(self): self._run_lua(script) def _test_handler_counts_across_lifecycle_paths(self): + """Keep handler and capture-reset counts stable across lifecycle paths.""" resource_source = self._fragment_script( self.resource_source_path, "Resource Ownership", @@ -747,6 +749,7 @@ def _test_handler_counts_across_lifecycle_paths(self): self._run_lua(script) def _test_handler_analyzer_reports_owned_resources(self): + """Require the resource analyzer to report every owned capture timer.""" analyzer = self.repo_root / "scripts" / "analyze_handlers.py" result = subprocess.run( [ @@ -1357,6 +1360,7 @@ def _test_debug_master_toggle_and_load_order(self): ) def _test_gui_script_names_and_order(self): + """Keep the capture script in the required GUI initialization order.""" self._load_gui_scripts() expected = [ "Toggles", @@ -1844,6 +1848,7 @@ def _test_debug_runtime_output_and_error_semantics(self): self._run_lua(script) def _test_debug_startup_boundary_and_system_coverage(self): + """Require capture diagnostics in the startup debug coverage map.""" gui_source = self._gui_lua_source() boot_source = self._gui_script("GUI Boot") refresh_source = self._gui_script("GUI Refresh") diff --git a/tests/test_output_capture.py b/tests/test_output_capture.py index 5f4a915..73ff2a0 100644 --- a/tests/test_output_capture.py +++ b/tests/test_output_capture.py @@ -12,6 +12,7 @@ class OutputCaptureTester: def __init__(self, _xml_file=None): + """Load the capture source and initialize regression-test state.""" self.repo_root = PROJECT_ROOT self.capture_path = ( self.repo_root @@ -32,6 +33,7 @@ def __init__(self, _xml_file=None): @staticmethod def _find_lua(): + """Return the first supported Lua interpreter available on PATH.""" for executable in ("lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"): path = shutil.which(executable) if path: @@ -39,6 +41,7 @@ def _find_lua(): return None def _capture_source(self): + """Extract the authored ASCII map capture Lua from its XML fragment.""" fragment = ET.fromstring( "" + self.capture_path.read_text(encoding="utf-8") + "" ) @@ -49,6 +52,7 @@ def _capture_source(self): @staticmethod def _mocks(): + """Provide the minimal Mudlet Lua API used by the capture parser.""" return r""" destination = {} destinationStyles = {} @@ -194,6 +198,7 @@ def _mocks(): """ def _run_lua(self, body): + """Execute one Lua assertion body with the production parser and mocks.""" source = self._mocks() + "\n" + self.capture_source + "\n" + body result = subprocess.run( [self.lua_path, "-"], @@ -207,6 +212,7 @@ def _run_lua(self, body): raise AssertionError((result.stderr or result.stdout).strip()) def _test_permanent_trigger_and_spacing_contract(self): + """Verify the permanent dispatcher and preservation of ordinary spacing.""" root = ET.fromstring( "" + self.trigger_path.read_text(encoding="utf-8") + "" ) @@ -257,6 +263,7 @@ def _test_permanent_trigger_and_spacing_contract(self): ) def _test_exact_room_fixture_and_formatting(self): + """Capture the reported room fixture without losing rows or ANSI styles.""" self._run_lua( r""" local rows = { @@ -297,6 +304,7 @@ def _test_exact_room_fixture_and_formatting(self): ) def _test_supported_heights_and_blank_rows(self): + """Accept supported map heights, blank rows, and ragged row widths.""" self._run_lua( r""" for _, height in ipairs({3, 9, 11, 13, 25}) do @@ -322,22 +330,36 @@ def _test_supported_heights_and_blank_rows(self): assert(#destination == 3) assert(destination[1] == " " and destination[2] == " . " and destination[3] == " ") assert(fitCalls[#fitCalls].rows == 3 and fitCalls[#fitCalls].columns == 8) + +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(19, ".")) == "row") +assert(feedLine(repeatedRow(18, "|")) == "row") +assert(feedLine(repeatedRow(21, "Y")) == "row") +assert(feedLine("") == "finished") +assert(#destination == 3, "ragged map row count changed") +assert(destination[2] == " " .. repeatedRow(18, "|")) +assert(fitCalls[#fitCalls].rows == 3 and fitCalls[#fitCalls].columns == 22) """ ) def _test_wilderness_and_multiple_blocks(self): + """Capture consecutive maps and every shared wilderness terrain glyph.""" self._run_lua( r""" assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") assert(feedLine("") == "started", "new opener did not replace capture") +local terrainSymbols = {"o", "m", "i", "`"} for row = 1, 21 do - assert(feedLine(repeatedRow(21, row == 11 and "*" or "~")) == "row") + local symbol = terrainSymbols[((row - 1) % #terrainSymbols) + 1] + assert(feedLine(repeatedRow(21, symbol)) == "row") end assert(feedLine("") == "finished") -assert(#destination == 21 and destination[1] == " " .. repeatedRow(21, "~")) -assert(destination[11] == " " .. repeatedRow(21, "*")) -assert(destination[21] == " " .. repeatedRow(21, "~")) +assert(#destination == 21 and destination[1] == " " .. repeatedRow(21, "o")) +assert(destination[2] == " " .. repeatedRow(21, "m")) +assert(destination[3] == " " .. repeatedRow(21, "i")) +assert(destination[4] == " " .. repeatedRow(21, "`")) +assert(destination[21] == " " .. repeatedRow(21, "o")) assert(clearCount == 2, "each opening marker must clear exactly once") assert(#fitCalls == 1 and fitCalls[1].kind == "wilderness") assert(fitCalls[1].columns == 23 and fitCalls[1].rows == 21) @@ -346,15 +368,21 @@ def _test_wilderness_and_multiple_blocks(self): ) def _test_invalid_and_mismatched_recovery(self): + """Keep failed blocks bounded until their matching close marker arrives.""" self._run_lua( r""" assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") assert(feedLine("This is ordinary prose.") == "aborted") assert(main[#main].text == "This is ordinary prose.") +assert(GUI.AsciiMapCapture.state.failed == true) assert(feedLine("Prompt> ") == "ignored") assert(main[#main].text == "Prompt> ") +local mainBeforeClose = #main +assert(feedLine("") == "aborted") +assert(#main == mainBeforeClose, "failed block closing marker leaked") assert(GUI.AsciiMapCapture.state == nil) +assert(timers["asciiMapCapture.inactivity"] == nil) assert(feedLine("") == "started") assert(feedLine(repeatedRow(19, ".")) == "row") @@ -362,11 +390,6 @@ def _test_invalid_and_mismatched_recovery(self): assert(main[#main].text == "") assert(feedLine("after mismatch") == "ignored") -assert(feedLine("") == "started") -assert(feedLine(repeatedRow(19, ".")) == "row") -assert(feedLine(repeatedRow(18, ".")) == "aborted") -assert(main[#main].text == repeatedRow(18, ".")) - assert(feedLine("") == "started") fireTimer("asciiMapCapture.inactivity") assert(GUI.AsciiMapCapture.state == nil) @@ -376,6 +399,7 @@ def _test_invalid_and_mismatched_recovery(self): ) def _test_destination_failures_preserve_current_line(self): + """Leave failed transfer rows visible while hiding their closing marker.""" self._run_lua( r""" map.minimap = nil @@ -389,7 +413,13 @@ def _test_destination_failures_preserve_current_line(self): failAppend = true assert(feedLine(repeatedRow(19, ".")) == "aborted") assert(main[#main].text == repeatedRow(19, "."), "append failure consumed row") +assert(GUI.AsciiMapCapture.state.failed == true) +assert(timers["asciiMapCapture.inactivity"] ~= nil) +local mainBeforeClose = #main +assert(feedLine("") == "aborted") +assert(#main == mainBeforeClose, "failed destination closing marker leaked") assert(GUI.AsciiMapCapture.state == nil) +assert(timers["asciiMapCapture.inactivity"] == nil) failAppend = false failClear = true @@ -399,6 +429,7 @@ def _test_destination_failures_preserve_current_line(self): ) def _test_safety_limit_and_next_map_recovery(self): + """Enforce row and column limits, then recover for the next map.""" self._run_lua( r""" local row = repeatedRow(19, ".") @@ -408,6 +439,16 @@ def _test_safety_limit_and_next_map_recovery(self): assert(main[#main].text == row, "oversized row was consumed") assert(feedLine("ordinary after limit") == "ignored") assert(main[#main].text == "ordinary after limit") +local mainBeforeClose = #main +assert(feedLine("") == "aborted") +assert(#main == mainBeforeClose, "limited block closing marker leaked") + +assert(feedLine("") == "started") +assert(feedLine(repeatedRow(257, ".")) == "aborted") +assert(GUI.AsciiMapCapture.state.failed == true) +mainBeforeClose = #main +assert(feedLine("") == "aborted") +assert(#main == mainBeforeClose, "wide block closing marker leaked") assert(feedLine("") == "started") assert(feedLine(row) == "row") @@ -417,6 +458,7 @@ def _test_safety_limit_and_next_map_recovery(self): ) def _test_lifecycle_reset_hooks(self): + """Require every lifecycle boundary to clear capture state and timers.""" sources = { "cleanup": self.repo_root / "theGUI" @@ -455,6 +497,7 @@ def _test_lifecycle_reset_hooks(self): ) def run_tests(self): + """Run every output-capture regression and collect structured results.""" print("Running output capture regression tests...") if not self.lua_path: self.errors.append("lua interpreter not found in PATH") @@ -510,6 +553,7 @@ def run_tests(self): return passed == len(tests) def get_results(self): + """Return results in the shared test-runner format.""" return { "test_results": self.test_results, "errors": self.errors, @@ -518,6 +562,7 @@ def get_results(self): def main(): + """Run the output-capture regression suite as a standalone command.""" tester = OutputCaptureTester() success = tester.run_tests() return 0 if success else 1 diff --git a/theGUI/build.yaml b/theGUI/build.yaml index 70df8c7..92ddcdb 100644 --- a/theGUI/build.yaml +++ b/theGUI/build.yaml @@ -5,7 +5,7 @@ package: name: "LuminariGUI" - version: "2.0.4.046" + version: "2.0.4.047" output: file: "../LuminariGUI.xml" diff --git a/theGUI/src/scripts/gui/37_ascii_map_capture.xml b/theGUI/src/scripts/gui/37_ascii_map_capture.xml index 507758b..727e6d9 100644 --- a/theGUI/src/scripts/gui/37_ascii_map_capture.xml +++ b/theGUI/src/scripts/gui/37_ascii_map_capture.xml @@ -33,7 +33,7 @@ local MAP_KINDS = { wilderness = { opening = "<WILDERNESS_MAP>", closing = "</WILDERNESS_MAP>", - allowed = allowedCharacters([[ [].,Yn^~=UX|-+M*CA:&S?#\/]]), + allowed = allowedCharacters([[ [].,Yn^m~=UX|-+oM*CAi:&S?#\/`]]), padding = function() if not map or type(map.calcMinimapPadding) ~= "function" then return 0 @@ -190,9 +190,6 @@ local function validRow(state, text) return false, "row contains a character that is invalid for " .. state.kind .. " maps" end end - if text ~= "" and state.width ~= 0 and #text ~= state.width then - return false, "row width changed inside map block" - end return true end @@ -274,6 +271,10 @@ function Capture.processLine(text) logError("could not consume closing marker: " .. tostring(deleteError)) return "aborted" end + if state.failed then + Capture.reset("failed map block closed") + return "aborted" + end finishCapture(state) return "finished" end @@ -285,10 +286,12 @@ function Capture.processLine(text) end end + if state.failed then return "ignored" end + local transferred, transferError = transferRow(state, text) if not transferred then - Capture.reset(transferError) - logError(transferError .. "; leaving current line visible") + state.failed = true + logError(tostring(transferError) .. "; leaving current line visible") return "aborted" end return "row"