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/.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/LuminariGUI.xml b/LuminariGUI.xml index a62c8b5..c151126 100644 --- a/LuminariGUI.xml +++ b/LuminariGUI.xml @@ -1,6 +1,6 @@ - + LuminariGUI @@ -255,66 +255,9 @@ end - Capture Wilderness Map - 0 0 @@ -327,110 +270,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 @@ -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,310 @@ 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/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/.gitkeep b/docs/ongoing-projects/.gitkeep new file mode 100644 index 0000000..e69de29 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..68e4d31 --- /dev/null +++ b/docs/ongoing-projects/OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md @@ -0,0 +1,384 @@ +# Preserve output spacing and capture complete ASCII maps + +Date: 2026-09-07 + +Status: Implemented and verified on `fix/output-spacing-ascii-map`; this is not +a release approval. + +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. + +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 + +### 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. 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 + 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 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. + +### 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..58aa738 100644 --- a/docs/ongoing-projects/TASK_LIST.md +++ b/docs/ongoing-projects/TASK_LIST.md @@ -162,6 +162,18 @@ in this folder. ## Feature backlog +- [x] Restore normal output spacing and capture complete ASCII maps using + [the investigation and fix plan](OUTPUT_SPACING_AND_ASCII_MAP_PLAN.md). + - 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/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..7a74c35 100644 --- a/tests/test_lifecycle_regressions.py +++ b/tests/test_lifecycle_regressions.py @@ -417,10 +417,12 @@ 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", ) + 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 +492,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 +523,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,12 +559,14 @@ 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) """ 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", @@ -646,6 +653,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 +718,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") @@ -731,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( [ @@ -759,6 +778,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}", @@ -1337,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", @@ -1353,6 +1377,7 @@ def _test_gui_script_names_and_order(self): "Buttons", "Room Info/Legend", "DrawFrames", + "ASCII Map Capture", "MSDP Protocol", "MSDP Gauges", "MSDP Actions", @@ -1823,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") @@ -1835,9 +1861,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 +1899,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..73ff2a0 --- /dev/null +++ b/tests/test_output_capture.py @@ -0,0 +1,572 @@ +#!/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): + """Load the capture source and initialize regression-test state.""" + 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(): + """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: + return path + 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") + "" + ) + 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(): + """Provide the minimal Mudlet Lua API used by the capture parser.""" + 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): + """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, "-"], + 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): + """Verify the permanent dispatcher and preservation of ordinary spacing.""" + 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): + """Capture the reported room fixture without losing rows or ANSI styles.""" + 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): + """Accept supported map heights, blank rows, and ragged row widths.""" + 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) + +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 + 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, "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) +assert(#main == 0, "successful maps leaked into main output") +""" + ) + + 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") +assert(feedLine("") == "aborted") +assert(main[#main].text == "") +assert(feedLine("after mismatch") == "ignored") + +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): + """Leave failed transfer rows visible while hiding their closing marker.""" + 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.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 +assert(feedLine("") == "ignored") +assert(main[#main].text == "", "clear failure consumed opener") +""" + ) + + 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, ".") +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") +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") +assert(feedLine("") == "finished") +assert(#destination == 1 and destination[1] == " " .. row) +""" + ) + + def _test_lifecycle_reset_hooks(self): + """Require every lifecycle boundary to clear capture state and timers.""" + 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): + """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") + 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 results in the shared test-runner format.""" + return { + "test_results": self.test_results, + "errors": self.errors, + "warnings": self.warnings, + } + + +def main(): + """Run the output-capture regression suite as a standalone command.""" + 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..92ddcdb 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.047" 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..727e6d9 --- /dev/null +++ b/theGUI/src/scripts/gui/37_ascii_map_capture.xml @@ -0,0 +1,304 @@ + + + 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