add: starter interface with health bars, map and chat for new players (#9454)

#### Brief overview of PR changes/additions
- New "Mudlet base UI" package auto-installed for new players on every
game: adjustable right-side dock with map (placeholder until the game is
mapped), tabbed chat (All/Tells/Channels with unread badges) and themed
HP/MP/MV/XP gauges - only ever built from data the game actually
provides
- Chat capture: GMCP Comm.Channel.Text when available, otherwise a
generic additive trigger layer (line shapes distilled from an audit of
all 216 packages in the Mudlet package repository); nothing is ever
gagged, and the trigger layer retires itself the moment GMCP chat
appears
- Vitals ladder for the gauges: GMCP (several dialect spellings) > MSDP
(negotiated automatically) > self-sufficient prompts (labeled cur/max or
percents) > score-screen harvesting; when a prompt only shows bare
currents, Mudlet sends `score` once - visibly, with an announcement - to
learn the maxima. Maxima are never guessed. An enemy-health bar appears
during fights on games that report it
- `baseui hide` / `baseui show` opt-out persists across restarts;
first-run announcement defers to the UI tour; experienced players never
get the package
- Plays nice with games that provide their own interface: profiles for
games whose bundled loader installs the game's official UI (flagged in
TGameDetails: Carrion Fields, Icesus, MorgenGrauen, Medievia) skip the
starter UI entirely, and when a game pushes a `Client.GUI` package the
starter UI quietly stands aside (via the new `sysServerGuiInstalled`
event) - `baseui show` brings it back for players who prefer it
- `docs/package-capture-audits/` records the chat + vitals capture
audits (with provenance) behind the design

#### Motivation for adding to Mudlet
New players currently get a bare text screen; this gives every game -
GMCP-rich, MSDP-only or plain telnet - an immediate, honest starter UI
out of the box.

#### Other info (issues closed, discussion etc)
Progresses #3071 and #3070 (phase 1 - not closing them).

**Test case:** Fresh install → create a profile for any game → connect.
On GMCP/MSDP games the dock builds with ticking gauges and tabbed chat;
on a game whose prompt carries cur/max values, gauges appear once the
prompt repeats; on a bare-number prompt, `score` is sent once
(announced) to size the gauges; on pure chat games, tells/says/channels
are captured with no gauges invented. `baseui hide` removes it and
survives restart.



https://github.com/user-attachments/assets/56d207cc-5bad-4464-867c-1a810d41cf2f

---------

Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
This commit is contained in:
Vadim Peretokin 2026-07-26 08:43:21 +02:00 committed by GitHub
parent dabd95df36
commit 69cd06b1c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 14299 additions and 9 deletions

View file

@ -0,0 +1,20 @@
# Package-repository capture audits
Empirical basis for the starter UI ("Mudlet base UI") capture design: every package in
[mudlet-package-repository](https://github.com/Mudlet/mudlet-package-repository) (216 packages
at commit d2dead2, audited 2026-07-16) was analyzed for how it captures game text and
character data, with file-level provenance for every mechanism.
- `chat-capture-catalog.md` - chat/text capture: mechanism taxonomy, routing patterns,
per-channel regex library, GMCP dialects, and the layered capture design the starter UI
implements (79 packages, 281 mechanisms).
- `chat-capture-findings.json` - the raw chat corpus behind the catalog.
- `vitals-capture-catalog.md` - character data (hp/mana/xp/affects/enemy-hp): GMCP dialect
map, MSDP variables, prompt-shape families, and the maxima problem (63 packages,
451 mechanisms).
- `vitals-capture-findings.json` - the raw vitals corpus behind the catalog.
Method: per-package analysis with cross-checking against keyword-based recall lists, then a
second pass over any package flagged as capture-likely but reported empty. Counts of
cross-game convergence are by independent game, not package, to compensate for the corpus's
IRE/Achaea skew.

View file

@ -0,0 +1,541 @@
# Chat Capture in Mudlet Packages: Audit -> Design Catalog
**Scope:** 242 packages catalogued (216 real packages plus deduped file-name entries),
79 capture game text, 281 capture mechanisms total. This document turns that audit into
the design reference for chat capture in Mudlet's built-in starter UI (`base-ui`), which
today captures only `gmcp.Comm.Channel.Text`.
**Provenance convention:** every load-bearing claim cites the package name; verbatim
patterns cite file + the regex/substring as written in the package. Two source files feed
this: `findings.json` (full corpus, keyed by package) and `firstrun-recheck-findings.jsonl`
(deeper second pass over the 53 chat-heaviest packages, richer channel/tab notes). Where the
two disagree the recheck notes win; duplicate entries keyed by filename
(e.g. `AchaeaChatTabs.xml` vs `AchaeaChatTabs`) are merged.
**Honesty caveat up front:** the corpus is severely IRE/Achaea-skewed. Of ~24 games with any
chat capture, Achaea alone accounts for dozens of packages, while every non-IRE game is
usually represented by exactly ONE package - i.e. one author's pattern set. Convergence claims
below are counted across *independent games*, not packages, precisely to avoid inflating
coverage that a pile of Achaea packages would create.
---
## 1. Mechanism taxonomy, ranked by prevalence
Aggregate kind counts across all 281 mechanisms (many are NOT chat - see the split in each
row): trigger-regex 141, gmcp 40, trigger-substring 25, raw-line-hook 22, event-handler 12,
trigger-prompt 11, trigger-color 7, msdp 5, trigger-exact 3, alias 3, atcp 1.
### 1.1 trigger-regex (141) - the workhorse, but mostly NOT chat
Perl-regex triggers dominate the corpus, but they split three ways:
- **Chat capture** (the relevant slice): per-channel regexes that copy a matched comm line
into a tab. Examples:
- `avatar-mud-package` (Avatar MUD, YATCO), `package.xml` line 391:
`^\w+ tells you '.*'$` -> `selectString(line,1); copy(); ChatStuff.append('Tells'); deleteLine()`.
- `ErionMud-UI` (ErionMUD), `ErionMud-UI.xml` line 2921: ~40 bracket-channel regexes
(`^\[chat\]`, `^\[newbie\]`, `^(.*) tells you\,`, `^You (?<verb>ask|exclaim|say|chuckle|yell)`)
-> `ChatCaptures()` copies + `appendBuffer("ui.ChatMC")`.
- `Earthshaker-v0.1.4.xml` (Aetolia) line 8918: `^\((\w+)\)\:([\s]+)([A-Za-z-]+)...(says|say), "`
per-channel paren format -> `echoChat()`.
- `PRS.xml` (Procedural Realms, EMCO) line 161: `^< Chat \| (?<sender>.+) > (?<msg>.+)$`.
- **Map/prompt/combat capture** (the majority): ASCII-map redirects
(`achaea-fancy-gui`, `Achaean System` Wilderness/Ocean, `MedUI`, `mag-mudlet-aardwolf-gui`
MiniMap), prompt gauges (`avatar-mud-package` Prompt, `ErionUI` PromptCapture), combat/DPS
(`dpstest`, `cofudlet` `^(?<name>.+) is DEAD!!!`), inventory/status scrapes
(`ire-ab-plus`, `inventory-lister`, `wisr-reporter`).
- **Reliability/portability:** highest precision when the game's chat format is stable, but
every regex is game-specific - none port across games unchanged. FP risk scales with how
loose the anchor is (a bare `.* says, '.*'` will eat NPC room speech).
### 1.2 gmcp (40) - structured, portable, but only ~a third is chat
GMCP event handlers. The chat-relevant subset is the `Comm.Channel` family plus a few custom
namespaces; the rest is `Room.Info` (mapper), `Char.Vitals`/`char.vitals` (gauges),
`Char.Items` (inventory).
- **Canonical chat:** `gmcp.Comm.Channel.Text` with `{channel, text, talker}`. This is the
single most reused chat mechanism in the corpus:
- `AchaeaChatTabs` (Achaea) `AchaeaChatTabs.xml` line 263 - **THE canonical chat package**;
`registerAnonymousEventHandler("gmcp.Comm.Channel.Text", chatCapture)`, `ansi2decho` then
`chatEMCO:decho(channel, txt)`. Notably: "Nothing gagged from main window - pure additive
capture."
- `Achaean System` line 27277, `LusterniaChatTabs.xml` line 149, `Chatter.xml` line 745
(Threshold RPG), `Icesus.xml` line 1329, `Ishar.xml` line 3481.
- **Reliability/portability:** by far the most reliable and portable - the server pre-splits
channel from text, so no regex fragility, works across every IRE game and any game that
implements the IRE GMCP `Comm.Channel` module. This is why it is Layer 1 of the design.
Requires an opt-in handshake (see 4.1).
### 1.3 trigger-substring (25) - simple begin-of-line/substring tags
Used where a channel has a fixed literal prefix. Chat examples:
- `MedUI.xml` (Medievia): `[CLAN]`, `[FORM]`, `[TOWN]`, `[CHAT about anything]`
(begin-of-line substrings, type 2) -> `MedChat.appendToChatPanel()` -> EMCO tabs.
- `Mudlet-LOTJ-Client` `Mudlet-LOTJ-Client.xml` line 21: a single 24-pattern matcher
(`(OOC)`, `CommNet`, `(NEWBIE)`, `(IMM)`, `[Incoming Transmission`, ...) -> one `Chatbox`
MiniConsole.
- `Akayan GUI Creator` (Achaea): `(Newbie):`, `(Market):`, `(Party):` -> Vyzor.Chat tabs.
- `tab-chat-and-bars-for-t2t` (The Two Towers): `You tell`, `^ (OOC) `, `[ PoT ]`, `+ Guild`.
- **Reliability/portability:** cheap and robust when the tag is truly literal and unique, but
tags are entirely game-specific and short substrings collide (LOTJ's `{` pattern "may catch
prompt", per its own author note).
### 1.4 raw-line-hook (22) - one always-on trigger, parse in Lua
A single catch-all trigger (`return true`, `^(.*)$`, or `tempLineTrigger`) that hands every
line to Lua `string.match` parsers. Chat example is the most sophisticated hand-rolled system
in the corpus:
- `DarkMistsCompanion.xml` (Dark Mists) line 17842: `onNewLine` (`return true`) ->
`handleCommunicationLine(line)` which runs ~15 `string.match` channel parsers
(say/tell/yell/gtell/ooc/newbie/house + telepathic variants) and raises
`dmapi.communication.*` events; a `ChatHistory` sink `cecho`s them into one MiniConsole.
- Others are non-chat: `generic_mapper` (room-title buffer), `simple-logger` (disk logging),
`delay-scrolling`, `agnosticDB` table scrapes, `TelegramConnector` (relays to Telegram).
- **Reliability/portability:** flexible (all logic in Lua, easy multi-line handling) but the
always-on `return true` trigger runs on *every* line - a real per-line cost, and the parser
set is still game-specific.
### 1.5 event-handler (12) - non-GMCP event sinks
Named/anonymous handlers on custom or protocol events. Chat examples:
- `MedUI` / `MMChat`: `sysMMCPChatMessage`/`sysMMCPMessage` (MudMaster Chat Protocol peer
chat) -> EMCO `MMCP` tab.
- `DarkMistsCompanion` line 16925: sink binding ~27 `dmapi.communication.*` events.
- `nannymud-starter-module`: `multiplayer_share_communication` mirrors chat across open
profiles.
### 1.6 trigger-prompt (11) - prompt lines, almost entirely NOT chat
Prompt-type or prompt-detector triggers feeding gauges/status
(`avatar-mud-package`, `ErionMud-UI` 30-field `#N` prompt, `nannymud-starter-module`,
`MUDKIP_Mud2`, `diku-prompt-handler`). None are chat; listed to keep the split honest. These
routinely `deleteLine()` the prompt - the exact behaviour the chat design must avoid (see 2).
### 1.7 trigger-color (7) - colour-gated capture, NOT chat
`isColorTrigger`/`isColorizerTrigger` matches by ANSI colour: `DarkMistsCompanion` room-name
(bright yellow), `mag-mudlet-aardwolf-gui` teal remote-socials, `MUDKIP_Mud2` dreamword,
`mm_package` novice-clan (also colorizes). Mostly recolor-in-place or map/status, not tab chat.
### 1.8 msdp (5) - all HUD/affects/map, zero chat
`AbandonedRealms` (AFFECTS/ROOM_MAP/ROOM_NAME/WORLD_TIME) and `realms-of-despair-ui`
(~50 `msdp.*` into gauges/labels). **No package in the corpus captures chat over MSDP.**
Relevant conclusion: MSDP is not a chat transport worth targeting for the starter UI.
### 1.9 trigger-exact (3) / alias (3) / atcp (1) - marginal
Exact-match gates for capture blocks (`HelpBrowsinator` `BROWSINATOR START`,
`ire-ab-plus`); aliases for outgoing self-echo (`fed2ui`/`fed2-tools` `^say`, `^tell`,
`^com`); one legacy ATCP `RoomBrief` (`achaea-rat-counter`). ATCP is effectively dead - one
occurrence, superseded by GMCP.
---
## 2. Routing patterns - how captured text is moved
Five routing idioms recur. This matters more than the matching mechanism, because the routing
choice is what makes capture *safe* or *destructive*.
### 2.1 Additive copy: `selectCurrentLine() + copy() + appendBuffer(tab)` (NO deleteLine)
The dominant *safe* pattern, and the one the canonical package uses. Copies the current line
as full rich text (colour preserved) into the tab MiniConsole, leaving the original in the
main window untouched.
- `AchaeaChatTabs` - "Nothing gagged from main window - pure additive capture."
- `basic-materia-magica-ui-and-gmcp-mapper`/`mm_package.xml` line 1026: `Caps` trigger,
`selectCurrentLine(); copy(); my_miniconsole:appendBuffer()` - "no deleteLine, so the line
also remains in the main console."
- `ErionMud-UI` `ChatCaptureNoDelete` (line 3253) - `bDeleteLine=true` path keeps the line in
main.
- `Mudlet-LOTJ-Client` - `deleteLine()` shipped but commented out, "kept enabled so main
window still logs."
- **Preserves colour:** yes (rich-text copy). **Safe under logging/prompt features:** yes.
### 2.2 Gag + redirect: `copy() + appendBuffer(tab) + deleteLine()/deleteFull()`
Same copy, then removes the line from main. The most common *trigger-based* tab-chat idiom.
- `avatar-mud-package` (YATCO), `Akayan GUI Creator` (Vyzor), `ErionMud-UI` `ChatCapture`,
`mag-mudlet-aardwolf-gui` (`MAGU.moveSelected`), `DSL PNP 4`, `tab-chat-and-bars-for-t2t`,
`materia-magica-gui`, `Earthshaker` (`deleteFull()` in `echoChat`), `XAMM for CoffeeMud`.
- **Preserves colour:** yes. **Breaks under deleteLine-sensitive features:** yes - this is the
hazardous idiom. It removes the line from the main-window log, can interfere with
`promptEnd`/prompt detection, and starves any downstream trigger that expected the line. If
a game also emits the same chat over GMCP, gagging here plus a GMCP tab yields double
handling and a main window that silently drops lines.
### 2.3 Gag + re-echo compaction: `deleteLine()` then `cecho()` a reformatted line
Delete the raw line and print a condensed replacement, usually back into MAIN (not a tab).
Mostly NON-chat: `achaea-inventory-organizer`, `ire-ab-plus` (skill table),
`tracking-script` (10 track-age triggers), `transmutation-calculator`, `alertness-*`,
`plant-harvester`, `inventory-lister`. Same deleteLine hazards as 2.2; relevant only as an
anti-pattern for the starter UI.
### 2.4 decho/cecho into tabs (the GMCP path)
GMCP hands you a ready string, so there is no line to `copy()` - you `decho`/`cecho` directly
into the tab.
- `AchaeaChatTabs`, `LusterniaChatTabs`: `ansi2decho(text)` then `chatEMCO:decho(channel,txt)`
- **colour-preserving** because the server's ANSI is converted to decho.
- `Achaean System`: `ansi2decho` + strip MXP/RGB + `[HH:MM]` timestamp -> `decho(window,text)`.
- `Chatter`: `ansi2decho` -> `Chatter.tabs[name].console:decho()`.
- `cofudlet`: `stripColors` then `cecho(color..msg)` - **loses per-char colour** (deliberate,
applies one per-channel colour).
- `Ishar`: `setFgColor` + `echo(text)` raw (never cecho) so `<`,`>`,`#` render literally -
a reminder that GMCP text can contain markup you must NOT interpret.
- **Never gags** in these packages (there is nothing in the main window to gag - GMCP is
out-of-band), which is exactly why the GMCP path is inherently additive and safe.
### 2.5 EMCO / YATCO / Vyzor frameworks
Prebuilt tabbed-MiniConsole engines that own the routing:
- **EMCO** (demonnic, MDK) - the current standard. `EMCO:append(tab)` does
`selectCurrentLine(); copy(); console:appendBuffer(); allTab:appendBuffer(); if self.gag
then deleteLine()` (`emco.lua` line 1660). Used by `AchaeaChatTabs`, `LusterniaChatTabs`,
`MedUI`, `PRS`, `HelpBrowsinator`, `EMCOChat`, `fed2-tools`. Auto-logs each tab to
`log/Chatbox/YYYY/MM/DD/tab.html`. Gag is opt-in and off by default.
- **YATCO** (older, `ChatStuff`/`demonnic.chat`) - `avatar-mud-package`, `tls-mud-chat`,
`tab-chat-and-bars-for-t2t`.
- **Vyzor.Chat** - `Akayan GUI Creator`, `materia-magica-gui`.
- All three converge on the same core: `selectCurrentLine+copy+appendBuffer` into per-tab
MiniConsoles plus an aggregate "All" tab, with optional (off-by-default) gag.
**Dominant idiom:** additive `selectCurrentLine+copy+appendBuffer` (colour-preserving), fed
either by a trigger (2.1) or by EMCO (2.5), and `ansi2decho + decho` for GMCP (2.4). Gagging
(2.2/2.3) is common in trigger packages but is the deleteLine-sensitive hazard the starter UI
should not adopt by default.
---
## 3. Chat-pattern library, by channel semantics
Verbatim patterns as written in the packages, grouped by channel. Each entry: game, package,
file, pattern. Structural shapes and cross-game convergence counts follow each group.
### 3.1 say / says (room speech)
- Dark Mists - `DarkMistsCompanion.xml`:5922 - `^(.*) says, '(.*)'$` ; own `^You say, '(.*)'$`
- Avatar MUD - `avatar-mud-package/package.xml`:453 - `^You say '.*'$` ; `^\w+ says '.*'$`
- Aetolia - `Earthshaker-v0.1.4.xml`:9258 - `^[^<>()].+ says, ".+.\"$`
- Procedural Realms - `PRS.xml`:224 - `^(?<sender>.+) say(?<s>s)?, '(?<msg>.+)$`
- Materia Magica - `materia-magica-gui` (MMKilla.xml):257 - `^You say, '.*'$` ; `.* says, '.*'$`
- Federation 2 - `fed2-tools.xml`:41305 - `^(\w+) (says|asks), "(.+)$`
- Icesus - `Icesus.xml`:1327 - GMCP `gmcp.Room.Speech` (kind=say), rendered `talker: text`
**Shape:** `^Name says, '<quote>'` (single or double quote varies; IRE uses `"`, Diku/ROM/LP
use `'`). **Convergence: 6 independent games** on the verb-quote shape. **FP hazard: HIGHEST of
any channel** - NPC room speech uses the identical format (`The guard says, 'Halt!'`). Icesus
*deliberately does not subscribe* to `Room.Ambient` to keep NPC narration out of the say pane
(`Icesus.xml` note). Any generic say trigger will capture NPC speech unless the game separates
it out of band.
### 3.2 tell - incoming
- Dark Mists - `DarkMistsCompanion.xml`:5897 - `^(.*) tells you, '(.*)'$`
- Avatar MUD - `avatar-mud-package/package.xml`:391 - `^\w+ tells you '.*'$`
- Achaea - `Achaean System.xml`:10513 - `^tells you\, "(.+)"$`
- Aetolia - `Earthshaker-v0.1.4.xml`:8958 - `^([a-zA-z]+) tells you(| in .+), "`
- ErionMUD - `ErionMud-UI.xml`:2921 - `^(.*) tells you\,`
- Materia Magica - `materia-magica-gui`:352 - `^\w.* tells you '.*'$`
- NannyMUD - `nannymud-starter-module/package.xml`:248 - `(.+) (tells) you: (.+)` (colon form)
- Akayan/Achaea - `Akayan GUI Creator.xml`:35 - `^(.*?) tells you(.*?)`
**Shape:** `^Name tells you[,:]? <delim>message`. The phrase `tells you` is the invariant;
what follows (`, '...'`, `, "..."`, `: ...`, or bare) varies. **Convergence: 8 independent
games.** **FP hazard: LOW-MODERATE** - much safer than say. NPCs rarely "tell you" with a
quoted string; requiring a following quote or colon (`tells you[,:]\s*['"]`) removes almost
all remaining NPC false positives. This is the single strongest cross-game candidate.
### 3.3 tell - outgoing
- Dark Mists - `DarkMistsCompanion.xml`:5897 note - `^You tell (.*), '(.*)'$`
- Avatar MUD - `avatar-mud-package/package.xml`:391 - `^You tell \w+ '.*'$`
- Achaea - `Achaean System.xml`:10513 - `^You tell (\w+)\, "(.*)"$`
- Aetolia - `Earthshaker` Tells group - `^(You) tell `
- Materia Magica - `materia-magica-gui`:352 - `^You tell \w+ '.*'$`
- Fed2 (comm) - `fed2-tools.xml`:43594 alias - `^(?:tb|tell)\s+(\w+)\s+(.+)`
**Shape:** `^You tell <name> <delim>`. **Convergence: 6 independent games.** **FP hazard: LOW**,
with one caveat: `You tell the group` is a guild/party channel, not a private tell (see 3.5) -
a naive `^You tell ` grabs both, so order the group pattern first.
### 3.4 shout / yell
- Dark Mists - `DarkMistsCompanion.xml`:6038 - `^(.*) yells, '(.*)'$` (+ `yells in panic`)
- Materia Magica - `materia-magica-gui`:504 - `^You yell '.*'$` ; `.* yells '.*'$`
- Aetolia - `Earthshaker-v0.1.4.xml`:9002 - ` shouts, "` (substring; ~35 shout/thunder/bellow
variants)
- Aetolia - `Earthshaker` AllChat - `^.+ yells\, ".+"$`
**Shape:** `^Name yells, '<quote>'` / ` shouts, "`. **Convergence: 3 independent games.** FP
hazard MODERATE (some NPCs yell). Thin evidence - defer to per-game packs.
### 3.5 guild / clan / house / order / formation (org channels)
- Dark Mists (house) - `DarkMistsCompanion.xml`:6111 - `^%[(.*)%] (.*)%: (.*)$`, gated by a
`HOUSE_CHANNELS` whitelist (CONCLAVE/CRUSADER/LIGHT/BRETHREN/...) so it does not match every
`[X] Y: Z` line.
- Avatar MUD (group) - `package.xml`:426 - `^You tell the group '.*'$` ; `^.+ tells the group '.*'$`
- Aetolia (guild) - `Earthshaker-v0.1.4.xml`:9332 - `^\((\w+)\)\:...say, ".*"$` (paren-channel)
- Materia Magica (clan) - `mm_package.xml`:1026 - `^\[CLAN\] (.*)$` ;
`(.*) tells the formation (.*)` ; `^You tell the formation (.*)`
- Medievia - `MedUI.xml`:3700 - `[CLAN]`, `[FORM]` substrings
- Aardwolf - `mag-mudlet-aardwolf-gui/package.xml`:289 - `^({chan ch=[^}]*})(.*)$` (`{}` markers)
- ErionMUD - `ErionMud-UI.xml` - `^\[faith\]`, `^\[secrets\]`, `^\(Friend\)`, `^\(Gtell\)\:`
**Two shapes here.** (a) Paren-channel `(<Chan>): Name says, "..."` - IRE-specific
(Achaea/Aetolia). (b) Bracket-tag `[<TAG>] Name: message` - Dark Mists, Materia Magica,
Aardwolf (with `{}`), ErionMUD. **Convergence on bracket-tag: 4-5 independent games**, but the
tag alphabet is entirely per-game and bracket-tags collide with prompt/status lines
(`[HP: ...]`, `[Exits: ...]`), which is why Dark Mists uses an explicit channel whitelist.
Too game-specific for a generic trigger; ideal for per-game packs (Layer 3).
### 3.6 newbie
- Dark Mists - `DarkMistsCompanion.xml`:6061 - `^%[NEWBIE%] (.*)%: (.*)$` (+ Discord relay
variant `^%[NEWBIE via Discord%] ...`)
- Akayan - `Akayan GUI Creator.xml`:58 - `(Newbie):` (begin-of-line substring)
- Aetolia - `Earthshaker-v0.1.4.xml`:8895 - `^\((Newbie)\)\:...(says|say), "`
- ErionMUD - `ErionMud-UI.xml` - `^\[newbie\]`
**Shape:** bracket/paren-prefixed `[Newbie]` / `(Newbie):`. **Convergence: 4 independent
games** on the literal word "Newbie" in a bracket/paren prefix. The word is stable but the
delimiter is not. Per-game pack material.
### 3.7 market / trade / auction
- Procedural Realms - `PRS.xml`:203 - `^< Trade \| (?<sender>.+) > (?<msg>.+)$`
- Akayan - `Akayan GUI Creator.xml`:79 - `(Market):`
- Aetolia - `Earthshaker-v0.1.4.xml`:9075 - `^\((Market)\)\:...`
- Materia Magica - `materia-magica-gui`:296 - `^AUCTION: .*`
- Federation 2 - `fed2-tools.xml`:41406 - `^\+{3} The exchange display shows the prices for (.+) \+{3}$`
**Shape:** literal `Market`/`Trade`/`AUCTION` in a bracket/paren/prefix. **Convergence: 4
games** but with four different delimiters. Per-game pack material.
### 3.8 OOC / general chat
- Dark Mists - `DarkMistsCompanion.xml`:6097 - `^%[OOC%] (.*)%: (.*)$` (+ own `^%[OOC%] to`)
- Medievia - `MedUI.xml`:3808 - `[CHAT about anything]`
- Edge of Midnight - `Edge of Midnight.xml`:1117 - `gmcp.comm.channel` where `.isooc` is true
- ErionMUD - `ErionMud-UI.xml` - `^\[chat\]`
- The Two Towers - `tab-chat-and-bars-for-t2t/package.xml`:197 - `^ (OOC) `
**Shape:** literal `OOC`/`chat` prefix, delimiter varies. **Convergence: 5 games.** Same story
- stable keyword, unstable framing. Per-game pack material.
### 3.9 Structural-shape summary
| Shape | Example | Independent games | Generic-trigger viability |
|---|---|---|---|
| `^Name tells you[,:] '<q>'` (incoming tell) | `^(.*) tells you, '(.*)'$` | 8 | **Viable** (low FP with quote/colon anchor) |
| `^You tell <name> ...` (outgoing tell) | `^You tell \w+ '.*'$` | 6 | **Viable** (order after group) |
| `^Name says, '<q>'` (say) | `^(.*) says, '(.*)'$` | 6 | **Risky** (NPC speech = same format) |
| `[<TAG>] Name: msg` (org/newbie/ooc) | `^%[(.*)%] (.*)%: (.*)$` | 5 | **No** (per-game tags, prompt collisions) |
| `(<Chan>): Name says, "..."` (IRE paren) | `^\((\w+)\)\: ...says, "` | 2 (Achaea, Aetolia) | **No** (IRE-only; use GMCP instead) |
| `< Chan \| Name > msg` (angle-pipe) | `^< Chat \| (.+) > (.+)$` | 1 (PRS) | **No** (single game) |
---
## 4. GMCP reality check
### 4.1 Namespaces observed
- **Canonical:** `gmcp.Comm.Channel.Text` with `{channel, text, talker}`.
`AchaeaChatTabs`, `Achaean System`, `LusterniaChatTabs`, `Chatter` (Threshold), `Icesus`.
Enabled by a login handshake: `AchaeaChatTabs` / `LusterniaChatTabs` register on
`gmcp.Char.Name` and `sendGMCP('Core.Supports.Add ["Comm.Channel 1"]')`.
- **Canonical, split sub-nodes:** Icesus subscribes `Comm.Channel.Text`, `Comm.Channel.Tell`
*and* `Room.Speech` separately (`Icesus.xml`:1327-1329, `Core.Supports.Set 'Comm.Channel.Tell 1'`...).
Ishar uses `gmcp.Comm.Channel` (no `.Text` leaf) with `{channel, text, time}` and a login
backlog replay (`Ishar.xml`:3481).
- **Lowercase variant:** CoffeeMUD's `cofudlet` handles `gmcp.comm.channel` with `.chan`/`.msg`
fields (NOT `.channel`/`.text`) and subscribes via `Core.Supports.Add ["comm.channel 1"]`
(`cofudlet.xml`:4465). `Edge of Midnight` uses `gmcp.comm.channel` + `gmcp.comm.tell` with an
`.isooc` flag (`Edge of Midnight.xml`:1117). `Federation 2` (`fed2ui.xml`:2328) splits into
`gmcp.comm.com`, `gmcp.comm.tell`, `gmcp.comm.say`.
- **Fully custom namespaces:** `cleftofdimensions` pushes pre-rendered ANSI over
`gmcp.cleft.send.n92`/`n91`/`n93` (`cleftofdimensions.xml`:539). `rop-mudlet` uses
`gmcp.Chat.Message` with `{channel, speaker, text}` (`rop-mudlet-candidate.xml`:1519).
`PRS` gets its ASCII map (not chat) over `gmcp.Char.Output`.
- **MSDP / ATCP:** zero chat. MSDP (`AbandonedRealms`, `realms-of-despair-ui`) is all
HUD/affects/map; ATCP appears once (`achaea-rat-counter` `RoomBrief`) and is obsolete.
### 4.2 GMCP vs triggers - rough split
Counting *independent games* with any chat capture (~22-24 total):
- **GMCP-based chat:** Achaea, Lusternia, Aetolia (IRE, via `Comm.Channel`), Threshold, Icesus,
Ishar, CoffeeMUD, Edge of Midnight, Federation 2, Cleft, ROP - **~11 games**. But note almost
all are either IRE (sharing the same `Comm.Channel` module) or a single bespoke server
implementation.
- **Trigger-based chat:** Avatar MUD, Dark Mists, DSL, Materia Magica, Aardwolf, ErionMUD, LOTJ,
Medievia, Procedural Realms, NannyMUD, The Two Towers - **~12 games**. These are the classic
Diku/ROM/LP/custom servers with no GMCP comm module.
So roughly half the *games* need GMCP and half need triggers - but the corpus is dominated at
the *package* level by IRE/Achaea GMCP packages. Practically: GMCP covers the IRE family (a
large share of Mudlet's user base) with one handler; triggers are unavoidable for everyone
else, and each of those games is thinly evidenced (one package each). Do not over-extrapolate a
generic trigger set from that thin non-IRE tail.
---
## 5. Starter-UI chat-capture recommendation (`base-ui`)
A three-layer design. Layer 1 exists today; Layers 2-3 are the additions. The guiding
principle from the corpus: **capture additively, never gag by default** (the canonical
`AchaeaChatTabs` does exactly this).
### Layer 1 - GMCP (exists today)
Keep the `gmcp.Comm.Channel.Text` handler. Harden it with what the corpus shows:
1. **Keep the handshake.** `base-ui` already negotiates this (it calls
`gmod.enableModule("BaseUI", "Comm.Channel")` on GMCP enable, verified on the wire as
`Core.Supports.Add ["Comm 1","Comm.Channel 1"]` during testing) - matching what
`AchaeaChatTabs`/`LusterniaChatTabs` do. Without that handshake most IRE games send
nothing on the channel, so keep it when refactoring. The real coverage gap is games
that have no Comm.Channel at all - that is what Layers 2-3 address.
2. **Accept the lowercase variant.** Also register/read `gmcp.comm.channel` with `.chan`/`.msg`
(CoffeeMUD/`cofudlet`) and subscribe `Core.Supports.Add ["comm.channel 1"]`. Normalise
`{channel|chan, text|msg, talker|speaker}` into one internal shape before routing.
3. **Colour path:** `ansi2decho(text)` then `decho` into the tab (as `AchaeaChatTabs`), which
preserves the server's ANSI. Do NOT `cecho`/interpret markup on raw GMCP text - Ishar's
handler echoes raw precisely so `<`, `>`, `#` are not swallowed.
4. Route to a channel->tab map with an aggregate "All" tab, mirroring the well-worn
`channelToTab` tables (`AchaeaChatTabs`: `ct/armytell->City`, `ht/hnt->House`,
`gt/party->Group`, `tell->Tells`, `says/emotes->Local`, `ot->Order`, `newbie/market->Misc`).
### Layer 2 - a small, defensible generic trigger set (additive only)
Ship ONLY the shapes that multiple independent games converge on with acceptable FP, and ONLY
as additive capture (no gagging). Per the analysis in section 3.9, that is precisely two
patterns, with `say` offered as opt-in:
| # | Channel | Proposed pattern | Games | FP risk | Default |
|---|---|---|---|---|---|
| 1 | tell in | `^(\w[\w'-]*) tells you[,:]\s*['"]` | 8 | low (quote/colon anchor rejects most NPC lines) | ON |
| 2 | tell out | `^You tell (?:the group )?(\w[\w'-]*)[,:]?\s*['"]` (test group first, route accordingly) | 6 | low (`the group` -> group tab; else Tells) | ON |
| 3 | say | `^(\w[\w'-]*) says[,:]\s*['"]` / `^You say[,:]\s*['"]` | 6 | **HIGH - NPC room speech is identical** | **OFF (opt-in)** |
Rationale for the cutoffs:
- **Tell in/out are the only broadly-convergent, low-FP shapes.** The `tells you`/`You tell`
phrasing is near-universal across Diku/ROM/LP; the quote-or-colon anchor is what keeps NPC
narration out.
- **`say` is deliberately opt-in.** Six games use it, but it is the single highest-FP channel:
NPC speech is byte-identical (`The guard says, 'Halt!'`). Icesus's authors chose to drop
`Room.Ambient` for this exact reason. Enabling say by default would flood the Tells/Local
tab with mob chatter and immediately sour first-run impressions.
- **Everything else (org/newbie/market/ooc/shout, bracket-tag, paren-channel, angle-pipe) is
excluded from the generic set.** Their keywords are stable but their framing
(`[TAG]` vs `(Chan):` vs `< Chan | >`) is per-game, and bracket-tags collide with prompt and
status lines. These belong in Layer 3.
**Implementation approach.** Because `base-ui` is a Lua package, register the triggers at init
(mirroring `DSL PNP 4`, `agnosticDB`, `simple-logger`, which all use `tempRegexTrigger` at
runtime) rather than shipping an XML `TriggerGroup`. Concretely:
```lua
-- at package init / sysLoadEvent, guarded so it installs once
baseUI.chat.triggerIds = baseUI.chat.triggerIds or {}
local function routeChat(tab)
selectCurrentLine()
copy() -- rich-text copy: preserves colour
baseUI.chat.console[tab]:appendBuffer()
baseUI.chat.console.All:appendBuffer()
-- NO deleteLine(): additive capture, main window and logging untouched
end
table.insert(baseUI.chat.triggerIds,
tempRegexTrigger([[^\w[\w'-]* tells you[,:]\s*['"]]], function() routeChat("Tells") end))
```
Store the IDs so the set can be toggled/killed when the game is IRE (GMCP already covers it) or
when the user disables it. `appendBuffer` after `selectCurrentLine+copy` is the colour-
preserving primitive every framework in the corpus (EMCO/YATCO/Vyzor) is built on
(`emco.lua`:1660). Use `tempRegexTrigger` (not `tempTrigger`) so the anchors hold.
A permanent XML `TriggerGroup` is the alternative if the team prefers the triggers to be
user-visible/editable in the editor - trade-off is that a shipped XML group is harder to gate
per-game than runtime IDs.
### Layer 3 - per-game pattern packs (ship as data)
Model the per-game regexes as data keyed like Mudlet's built-in `TGameDetails` game registry
(game identity -> pattern set), loaded only when the connected profile matches. Structure:
```lua
baseUI.chat.packs = {
["Dark Mists"] = { -- from DarkMistsCompanion.xml
say = {[[^(.*) says, '(.*)'$]], [[^You say, '(.*)'$]]},
tell = {[[^(.*) tells you, '(.*)'$]]},
yell = {[[^(.*) yells, '(.*)'$]]},
ooc = {[[^%[OOC%] (.*): (.*)$]]}, -- (Lua-pattern form as shipped)
house = {[[^%[(.*)%] (.*): (.*)$]], whitelist = {"CONCLAVE","CRUSADER","LIGHT",...}},
},
["Aardwolf"] = { org = {[[^(\{chan ch=[^}]*\})(.*)$]]}, ... }, -- from mag-mudlet-aardwolf-gui
["ErionMUD"] = { chat = {[[^\[chat\]]]}, newbie = {[[^\[newbie\]]]}, ... },
-- ...
}
```
**Games with proven, corpus-sourced pattern sets ready to lift verbatim:**
| Game | Source package | What it provides |
|---|---|---|
| Achaea / Lusternia / Aetolia / Imperian (IRE) | `AchaeaChatTabs`, `LusterniaChatTabs`, `Achaean System` | GMCP `Comm.Channel` + full channel->tab maps (Layer 1 covers; no triggers needed) |
| Aetolia | `Earthshaker-v0.1.4.xml` | paren-channel say/tells/city/house/guild/market/clans/shout regexes |
| Avatar MUD | `avatar-mud-package/package.xml` | tell/group/public YATCO regexes |
| Dark Mists | `DarkMistsCompanion.xml` | say/tell/yell/gtell/ooc/newbie/house Lua-match set (+ telepathic variants) |
| Materia Magica | `mm_package.xml`, `materia-magica-gui` | clan/formation/tell/relay/auction/yell regexes |
| Aardwolf | `mag-mudlet-aardwolf-gui/package.xml` | `{say}`/`{tell}`/`{chan ch=}` tag markers |
| ErionMUD | `ErionMud-UI.xml`, `ErionUI 1.0.xml` | ~40 `[tag]`/`(tag)` bracket-channel regexes |
| Medievia | `MedUI.xml` | `[CLAN]`/`[FORM]`/`[TOWN]`/`[CHAT]` substrings (+ MMCP event) |
| Procedural Realms | `PRS.xml` | `< Chat\|Newbie\|Trade\|... >` angle-pipe regexes |
| LOTJ | `Mudlet-LOTJ-Client.xml` | 24 comm/transmission substring patterns |
| NannyMUD | `nannymud-starter-module/package.xml` | tells/says/whispers colon-format |
| The Two Towers | `tab-chat-and-bars-for-t2t/package.xml` | You tell / (OOC) / [PoT] / +Guild substrings |
| CoffeeMUD | `cofudlet.xml` | `gmcp.comm.channel` lowercase handler + channel map |
| Federation 2 | `fed2ui.xml`, `fed2-tools.xml` | `gmcp.comm.com/say/tell` handlers + comm-unit regexes |
| DSL | `DSL PNP 4/PNP/DSL_PNP_Chat.lua` | dynamically-assembled `channel_patterns` table |
Each pack's provenance is a real, shipping community package - so the patterns are field-tested
against the actual game, not guessed. Ship packs additively (same `routeChat`, no gag). Community
can contribute new packs as plain data.
### Anti-goals (learned from the corpus)
1. **Do not gag (`deleteLine`/`deleteFull`) by default.** It removes lines from the main-window
log, can break `promptEnd`/prompt detection, and starves downstream triggers. The canonical
`AchaeaChatTabs` gags nothing; `Mudlet-LOTJ-Client` and `mm_package` keep lines in main on
purpose. Gag must be strictly opt-in, off by default.
2. **Do not delete or rewrite the prompt.** The prompt-handling packages (`avatar-mud-package`,
`ErionMud-UI`, `MUDKIP_Mud2`, `diku-prompt-handler`) all `deleteLine()` the prompt; that is a
gauge/HUD concern, not chat, and it interacts badly with logging and prompt-end features.
3. **Do not ship a monolithic catch-all `^(.*)$` / `.*` chat trigger.** Only whole-screen
consumers do that (`DarkMistsCompanion` line hook, `delay-scrolling`, `tts-*`,
`simple-logger`), and it runs Lua on every single line. A generic chat set must be a few
anchored patterns, not one megamatcher.
4. **Do not enable `say` by default** - NPC room speech is indistinguishable from player say by
format alone; Icesus dropped `Room.Ambient` for this reason.
5. **Do not assume canonical GMCP casing/shape.** Handle lowercase `gmcp.comm.channel`
(`.chan`/`.msg`), split sub-nodes (Icesus/Ishar), and custom namespaces gracefully; normalise
before routing.
6. **Do not build one giant say/tell regex per game inside base-ui.** Keep per-game patterns as
external data (Layer 3), so adding a game is a data PR, not a code change.
---
## Appendix: honest coverage limits
- The corpus proves chat capture for ~22-24 games, but only Achaea/IRE is deeply sampled. Every
non-IRE game is a single package = one author's patterns; treat those regexes as a strong
starting point, not a validated spec.
- No corpus package captures chat over MSDP or (meaningfully) ATCP. Targeting those for chat
would be inventing coverage the corpus does not support.
- The generic Layer-2 set is intentionally minimal (2 patterns + opt-in say). The temptation to
add org/newbie/market generically is not supported: those channels converge on a *keyword* but
not on a *format*, so a generic trigger would either miss most games or over-match. They are
correctly Layer-3 per-game data.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,526 @@
# Character-Data (Vitals/Stats) Capture in Mudlet Packages: Audit -> Design Catalog
**Scope:** 247 packages audited; **63 capture character data** (vitals/stats), across **451
mechanisms**. This document turns that audit into the design reference for the vitals gauges in
Mudlet's built-in starter UI (`base-ui`), which today reads only `gmcp.Char.Vitals` hp/maxhp and
mp/maxmp and paints two gauges.
**Provenance convention:** every load-bearing claim cites the package name; verbatim patterns
cite file + line + the regex/GMCP path as written in the package. Source of record is
`vitals-findings.json` (keyed by package, one entry per mechanism with kind/stat/pattern/
maxima_source/routing/file/line/notes).
**Honesty caveat up front:** as with the chat corpus, this one is IRE-skewed *at the package
level* - Achaea/Aetolia/Lusternia account for a large share of the packages. To keep convergence
claims honest, everything below counts **independent games**, not packages. A generic-filename
aggregate entry (`package.xml`, 85 distinct community packages sharing that filename) is
attributed per-mechanism by its real `file` prefix, not lumped. Two entries are excluded from
capture counts as non-captures: `GUIFlex.xml` (a layout template whose "Health" gauge is fed
`math.random(100)` against a hardcoded max of 100 - a *labelled* gauge with no data feed) and
`rop-mudlet-v.1.4.0-NOTE` (0 mechanisms).
**The one surprise that reframes everything (vs the chat catalog):** for *chat*, GMCP was an
IRE-family story. For *vitals it is not.* Of 200 GMCP mechanisms, **139 come from non-IRE games
and only 61 from IRE**; **14 of the ~20 GMCP games are non-IRE** (Materia Magica, CoffeeMUD,
Medievia, Icesus, Ishar, Edge of Midnight, Procedural Realms, Federation 2, LOTJ, Rites of
Passage, Discworld, ClessidraMUD, MUME, plus generic templates). `Char.Vitals`-style GMCP has
genuinely spread beyond Iron Realms. The catch, quantified in section 2: the naive
`gmcp.Char.Vitals.hp/maxhp` reader base-ui ships today cleanly matches **only the IRE core**
(Achaea/Aetolia/IRE) - every non-IRE game diverges on casing, key name, or where `maxhp` lives.
So GMCP's reach is real, but capturing it requires a dialect-normalising reader, not the
current fixed-path one.
---
## 1. Mechanism taxonomy, ranked by prevalence
Aggregate kind counts across all 451 mechanisms in the 63 vitals packages:
**gmcp 200, prompt-regex 86, trigger-regex 78, msdp 38, score-parse 33, event-handler 6,
atcp 1** (plus 9 "other" enabling/handshake mechanisms). The independent-game count per kind is
the honest denominator and is given in each row.
| Kind | Mechanisms | Independent games | IRE share of games |
|---|---|---|---|
| gmcp | 200 | **20** | ~6 IRE / 14 non-IRE |
| prompt-regex | 86 | **15** | 3 IRE / 12 non-IRE |
| trigger-regex | 78 | **20** | ~5 IRE / 15 non-IRE |
| msdp | 38 | **3** | 0 IRE / 3 non-IRE |
| score-parse | 33 | **14** | 3 IRE / 11 non-IRE |
| atcp | 1 | 1 | 1 (obsolete) |
### 1.1 gmcp (200 mech, 20 games) - dominant, portable, but dialect-fractured
Structured out-of-band vitals via GMCP event handlers. Dominates by mechanism count and, unlike
chat, is broad across games. **Games:** Achaea, Aetolia, IRE-generic, Lusternia (via score not
gmcp), CoffeeMUD, Materia Magica, Medievia, Icesus, Ishar, Edge of Midnight, Procedural Realms,
Federation 2, LOTJ, Rites of Passage, Discworld, ClessidraMUD, MUME, plus `generic`/`Muxlet`
user-configurable readers.
- **Canonical IRE:** `gmcp.Char.Vitals.hp / .maxhp / .mp / .maxmp` - `Achaean System.xml:19097`,
`Earthshaker-v0.1.4.xml:21893` (Aetolia), `tls-mud-chat/package.xml:524` (IRE). Values are
**strings**; every IRE package `tonumber()`s them.
- **Reliability/portability:** highest - server pre-computes cur and max, no regex, no gagging,
inherently additive (there is no line in the main window to delete). This is why it is Layer 1.
But see 2: the *key/value shape* is not portable; six distinct dialects exist.
### 1.2 prompt-regex (86 mech, 15 games) - the trigger workhorse for non-GMCP games
Perl-regex triggers on the recurring game prompt. **Games:** Achaea, Lusternia, Avatar MUD,
Dark Mists, DSL, CKMud, CoffeeMUD, Materia Magica, NannyMUD, Realms of the Dragon, Two Towers,
Aardwolf, ErionMud, MUD2, generic-Diku. This is where the maxima problem lives (section 5): most
default game prompts carry **current values only**. Structural families are catalogued in
section 4.
- **Reliability/portability:** each regex is game-specific; several **require the user to
reconfigure their in-game prompt** first (DSL, Avatar, Diku, Nanny) or have the package
**auto-install a custom prompt** (ErionMud, Aardwolf). FP risk is generally low because
prompts are anchored, but the whole family is fragile to prompt-format drift.
### 1.3 trigger-regex (78 mech, 20 games) - events, affects, combat, level-ups
Non-prompt line triggers: affect on/off strings, level-up lines, gold/xp gain, enemy
health-descriptions, and protocol-in-band lines (GemStone/Lich `<progressBar>` XML,
`Aardwolf {stats}` statmon). **Games:** 20, the widest spread of any kind - but most are a
*single package per game* capturing affects or combat prose, not core hp/mp scalars.
- Notable: **LichConnect** (GemStone IV/DragonRealms via Lich proxy) parses Stormfront XML
`<progressBar id='health' value='..' text='..'/>` at `LichConnect/package.xml:541` - `value`
is already a **0-100 percentage**, `maxima=hardcoded`. **Aardwolf** `mag-mudlet-aardwolf-gui`
parses the `statmon` protocol `^{stats}(.*)$` into a positional array; hp = field 20 (cur) /
21 (max), **same-line** (`package.xml:889`).
- **Reliability/portability:** none port across games; affect-string sets are entirely
per-game and large (Materia Magica ~25-30 buff triggers, MedUI ~19 add/remove pairs).
### 1.4 msdp (38 mech, only 3 games) - thin, but standardized where present
MSDP variable handlers + `REPORT` subscription. **Only 3 games:** Realms of Despair, CKMud, and
AbandonedRealms. Realms of Despair uses the **standard MSDP names** (`HEALTH`/`HEALTH_MAX`/
`MANA`/`MANA_MAX`/`MOVEMENT`/`MOVEMENT_MAX`/`OPPONENT_HEALTH`...) at `realms-of-despair-ui/
package.xml:360`; CKMud uses **game-themed names** (`POWERLEVEL`/`KI`/`FATIGUE` +`_MAX`) at
`CK/CK.xml:2728`; AbandonedRealms requests **no vitals at all** (only AFFECTS/time/map).
- **Reliability/portability:** where a game speaks MSDP, `X`/`X_MAX` pairs are as clean as GMCP.
But the corpus is too thin (3 games) to call MSDP a broad win - detail and honest assessment
in section 3.
### 1.5 score-parse (33 mech, 14 games) - the maxima bootstrap layer
Parses the `score`/`status`/`fes` command screen, almost always to learn **maxima** the prompt
lacks (see section 5). **Games:** Achaea, Lusternia, Dark Mists, ROTD, ErionMud, Materia Magica,
DSL, MUME, CKMud, CoffeeMUD, NannyMUD, unknown-ROM, MUD2. Half of these **auto-send `score`**
(consent implications in section 5).
### 1.6 event-handler (6) / atcp (1) - marginal
Event-handler: GMCP negotiation sinks and cross-profile mirroring (Icesus `Core.Supports.Set`
burst, `Icesus.xml:1292`). ATCP appears exactly once and is obsolete (superseded by GMCP), same
as in the chat corpus.
---
## 2. GMCP dialect map
Every `Char.Vitals`-like shape observed, with the exact provenance. **This is the most
design-critical section for Layer 1**, because base-ui's current fixed reader
(`gmcp.Char.Vitals.hp/maxhp/mp/maxmp`) matches only row A cleanly.
### 2.1 Namespace / key shapes
| # | Shape | Games (provenance) | Value format |
|---|---|---|---|
| A | `gmcp.Char.Vitals.hp/.maxhp/.mp/.maxmp` (PascalCase, max **inside** Vitals) | Achaea `Achaean System.xml:19097`; Aetolia `Earthshaker-v0.1.4.xml:21893`; IRE `tls-mud-chat/package.xml:524`; CoffeeMUD-clean `cofudlet.xml:4511`; Ishar `Ishar.xml:940` (hp/mp but **no** maxhp/maxmp) | **strings** on IRE (need `tonumber`); numbers on CoffeeMUD |
| B | **lowercase** `gmcp.char.vitals.hp` | Edge of Midnight `Edge of Midnight.xml:995`; Materia Magica `mm_package.xml:2218`; CoffeeMUD `XAMM for CoffeeMud.xml:2280`; Federation 2 `fed2ui.xml:2265` | numbers |
| C | max in a **separate node** `gmcp.char.maxstats.maxhp` | Materia Magica `mm_package.xml:2246`; Edge of Midnight `Edge of Midnight.xml:992` (`char.maxstats.maxhp/maxmana/maxmoves`) | numbers |
| D | max in **`Char.Maxstats`** (Pascal, separate node) | Icesus `Icesus.xml:960` (`.maxhp/.maxmana/.maxmoves/.maxpsp`) | numbers |
| E | **camelCase** max `gmcp.Char.Vitals.maxHp/maxMana` | Medievia `MedUI.xml:802`, `MultiBot Core v1.11.xml:1709`; LOTJ `Mudlet-LOTJ-Client.xml:379` (`maxHp`) | numbers |
| F | **entirely different namespace** `gmcp.Char.player.hp/.maxHp` | Procedural Realms `PRS.xml:4235` (energy/maxEnergy for mana, stamina/maxStamina) | numbers |
| G | **group/member arrays** `gmcp.Group.Info.members[].hp/.hp_max` | Rites of Passage `rop-mudlet-candidate.xml:224`; CoffeeMUD group `cofudlet.xml:4396` (`.info.hp/.mhp`, `.mn/.mmn`) | numbers |
| H | **nested cur/max tables** `gmcp.char.vitals.stamina.cur/.max` | Federation 2 `fed2-tools.xml:32322`, `fed2ui.xml:2265` | numbers |
### 2.2 Mana key naming (a silent breaker)
Even among games that DO put max inside Vitals, the mana key diverges: `.mp` (Achaea, Aetolia,
Ishar, IRE), `.mana` (Icesus, Medievia, CoffeeMUD, Edge of Midnight, Mudlet-LOTJ), `.sp` (Materia
Magica - "spell points", `mm_package.xml:2218`), `.energy` (Procedural Realms). A reader keyed on
`.mp` alone silently drops mana for **most non-IRE games**.
### 2.3 What breaks a naive `gmcp.Char.Vitals.hp/maxhp/mp/maxmp` reader
Quantified against the hp/mp GMCP shapes: base-ui's fixed reader **fully matches only Achaea,
Aetolia, and IRE-generic** (row A, with `tonumber`). It fails at least one field for:
- **Casing:** Edge of Midnight, Materia Magica, CoffeeMUD, Federation 2 (lowercase `char.vitals`).
- **maxhp location:** Materia Magica, Edge of Midnight (`char.maxstats`), Icesus (`Char.Maxstats`).
- **mana key:** Icesus/Medievia/CoffeeMUD/Edge-of-Midnight (`.mana`), Materia Magica (`.sp`),
Procedural Realms (`.energy`).
- **camelCase max:** Medievia, MultiBot, LOTJ (`maxHp`/`maxMana`).
- **namespace:** Procedural Realms (`Char.player`), Rites of Passage / CoffeeMUD-group
(`Group...members[]`).
- **string values:** Achaea/Aetolia send hp as `"3480"`; arithmetic without `tonumber` fails.
### 2.4 Extra stats offered over GMCP (breadth beyond hp/mp)
Beyond hp/mp, GMCP games expose a wide, game-specific stat surface that a starter UI *could* map
but should treat as optional: movement/moves/stamina (Icesus, MedUI, cofudlet, fed2), IRE
willpower/endurance/ego/power (`Achaean System.xml:21038`, `tls-mud-chat/package.xml:528`),
psp (Icesus), Fury/blood/spark/essence/devotion (Aetolia `Earthshaker`), balance/equilibrium
as `"0"/"1"` strings (Achaea/Aetolia), limb states (`left_arm`/`right_arm`), xp/tnl in many
shapes (`.nl`, `.tnl`, `.tnlpct`, `xpForNextLevel`), enemy/target hp (`gmcp.IRE.Target.Info.hpperc`
Achaea; `char.status.enemypct` CoffeeMUD; `opponent_hp_pct` Ishar), and gold in many currencies
(Achaea `.gold/.bank/.unboundcredits/...`). **Only hp and mp are near-universal**; everything else
needs the per-game map (section 6, L3).
---
## 3. MSDP variable map
### 3.1 Variables real packages consume
| Game (package) | Vital variables consumed | Provenance |
|---|---|---|
| Realms of Despair (`realms-of-despair-ui`) | **`HEALTH`/`HEALTH_MAX`, `MANA`/`MANA_MAX`, `MOVEMENT`/`MOVEMENT_MAX`, `EXPERIENCE`/`_MAX`/`_MIN`/`_TNL`, `OPPONENT_HEALTH`/`_MAX`/`_NAME`/`_LEVEL`**, plus `MONEY`, `LEVEL`, `AFFECTS`, `STR..LCK` (+`_PERM`), `AC/HITROLL/DAMROLL/...` | `package.xml:360-374` (gauges), subscription `:97` |
| CKMud (`CK`) | `POWERLEVEL`/`POWERLEVEL_MAX`, `KI`/`KI_MAX`, `FATIGUE`/`FATIGUE_MAX`, `GODKI`/`_MAX`, `DARK_ENERGY`/`MAX_DENERGY`, `OPPONENT_HEALTH`/`_MAX`, +30 more | `CK/CK.xml:2728-2850`, subscription list `:2667` |
| AbandonedRealms (`AbandonedRealms Gui Add-on`) | **no vitals** - only `AFFECTS`, `WORLD_TIME`, `ROOM_MAP/NAME`, `AREA_NAME` | subscription `:22` |
Realms of Despair is the **only corpus package using the standardized MSDP vital names**
(`HEALTH`/`HEALTH_MAX`/`MANA`/`MANA_MAX`/`MOVEMENT`/`MOVEMENT_MAX`). CKMud proves the names are
**not guaranteed standard** - a Dragon Ball MUD renames health to `POWERLEVEL`, mana to `KI`.
### 3.2 REPORT subscription mechanics (who sends what, and when)
MSDP data only flows after the client subscribes with `sendMSDP("REPORT", <var>, ...)`. The
corpus shows two trigger points:
- **On connection, host-gated:** `realms-of-despair-ui` sends its ~60-var REPORT on
`sysConnectionEvent`, **only if** `getConnectionInfo()` host == `realmsofdespair.com`
(`package.xml:97`). This is the cleanest pattern - subscribe automatically, but only for the
game that speaks it.
- **On a protocol-ready trigger:** `AbandonedRealms` waits for `[INFO] MXP version 1.0 detected`
then sends REPORT (`:22`); an affects script also force-enables MSDP via
`setConfig('enableMSDP', true)` on connect.
- **On connect + re-subscribe on staleness:** `CK` sends `REPORT` for all vars on connect and
**re-subscribes if `msdp.UPDATE_EPOCH` goes stale** (`CK/CK.xml:2667/2678`) - a nice
self-healing idiom.
Note Mudlet must have MSDP enabled (`setConfig('enableMSDP', true)`) for any of this; two packages
force it on.
### 3.3 Could MSDP be a zero-config second layer for the starter UI? Honest assessment
**Partially, and only with a normaliser - do not overclaim.**
- **For:** where a game speaks standardized MSDP (Realms of Despair), `HEALTH/HEALTH_MAX` etc are
a drop-in cur/max pair as clean as GMCP, and subscription is a single automatic `sendMSDP
REPORT` on connect. MSDP reaches an ecosystem (SMAUG/ROM/Diku-derivatives) that largely has
**no GMCP** - so it is genuinely additive coverage, not overlap.
- **Against:** the corpus proves MSDP vitals for **only 3 games**, one of which (AbandonedRealms)
requests no vitals and another (CKMud) uses **non-standard variable names**. So a base-ui MSDP
layer must (a) subscribe defensively (`REPORT HEALTH HEALTH_MAX MANA MANA_MAX MOVEMENT
MOVEMENT_MAX`) and (b) accept that games renaming those (CKMud) still need a per-game map.
Subscribing costs almost nothing (one telnet sub-negotiation), so a **defensive standardized
REPORT on connect is low-risk and worth shipping** - just don't advertise universal coverage.
---
## 4. Prompt-shape families (86 regexes -> structural families)
Grouped by structure. For each: converging games, whether **max is same-line**, FP risk, and
whether the family is **zero-config** or requires the user to set/allow a specific in-game prompt.
### Family A - IRE stat-suffix `(\d+)h, (\d+)m, (\d+)e, (\d+)w` (Achaea) / `+ p, en` (Lusternia)
- **Games (3, IRE):** Achaea `Achaea Fancy GUI 1.0.xml:595`, `achaea-rat-counter/package.xml:743`;
Lusternia `lusternia-fancy-gui-v2.xml:606` (`h,m,e,p,en,w`).
- **Max:** NOT same-line -> `score-command-parse` (the dominant IRE pattern).
- **Zero-config?** The *prompt* is the IRE default (no user setup), BUT maxima need a `score`
bootstrap (section 5). FP risk **LOW** - the `Nh, Nm, Ne, Nw` suffix format is highly specific.
Trailing single letters (`x`/`e`/`b`) are affect/balance flags, parsed by filter-children.
### Family B - angle-bracket slash-pairs `<-cur/maxhp cur/maxmp cur/maxmv ... tnl-> <cur/max>`
- **Games (1):** Avatar MUD `package.xml:506`.
- **Max: SAME-LINE** (cur/max per stat, plus a monitored-target `<cur/max>`). FP **LOW**.
- **Zero-config? NO** - "assumes the user has set the Avatar MUD prompt to the exact form".
Also gags the prompt with `deleteLine()`.
### Family C - paren slash-pairs `(cur/max)(cur/max)(cur/max)` (Diku/ROM)
- **Games (1, generic-Diku):** `diku-prompt-handler` `package.xml:214`.
- **Max: SAME-LINE.** FP **LOW** (heavily anchored). **Zero-config? NO** - "Requires user to run a
Diku prompt formatted `(hp/maxhp)(mana/maxmana)(mov/maxmov) ...`".
### Family D - pipe-delimited multi-field `<name|curHP|maxHP|curM|maxM|...>` (~27 fields)
- **Games (1, DSL):** `DSL PNP 4` `DSL_PNP_Statusbar.lua:277`, and duplicate `PNP`.
- **Max: SAME-LINE** (field3/field4 = curhp/maxhp). FP **LOW**. **Zero-config? NO** - "REQUIRES
user to set the exact in-game prompt `prompt <%t|%h|%H|...`". `curhp` may be `?` when blinded.
### Family E - labeled `** HP: cur/max SP: cur/max` (LPMud)
- **Games (1):** NannyMUD `nannymud-starter-module/package.xml:59`.
- **Max: SAME-LINE.** FP **LOW**. **Zero-config? NO** - "Requires the user to configure the
in-game prompt to emit `** HP: h/H SP: s/S`".
### Family F - bracket `[HP:cur EP:cur]>>` (Two Towers)
- **Games (1, but 3 packages):** Two Towers `importThis.xml:67`, `tab-chat-and-bars-for-t2t/
package.xml:25`, `the-two-towers-exits-window/importThis.xml:67`.
- **Max: current only -> HARDCODED 240.** FP **LOW**. Zero-config for capture (default t2t
prompt) BUT the hardcoded max is **wrong for any character whose real max differs** - a
cautionary example, not a model.
### Family G - labeled verbose `Hp: N Gp: N Xp: N` (LPMud) + brief `Hp: cur(max)`
- **Games (1):** Realms of the Dragon `ROTD_GUI/ROTD_GUI.xml:207` (verbose, current-only) and
`:276` (brief `Hp: cur(max)`, **same-line**). Verbose max from `score` ("You have N (N) hit
points", `:244`) with a persisted-file fallback `ROTD_maxHP.txt`, else hardcoded 1000.
- Also ships a **user-configurable prompt parser** (`setPromptPattern`, `:1742`) with
`hpIndex`/`maxHPIndex` defaults 1/2 - the one package that explicitly generalises.
### Family H - angle-bracket stat-suffix `<123hp 456sp 789st>` / `<100Hp 50m 30mv>`
- **Games (2):** Materia Magica `MMKilla.xml:207` (max from `gmcp.char.maxstats`); CoffeeMUD
default prompt `<100Hp 50m 30mv>` documented at `XAMM for CoffeeMud.xml:643` (but only detected,
routes user to `xamm set prompt`). **Max: not same-line** (gmcp/score). FP **LOW-MODERATE**.
### Family I - custom marker-delimited `#1<CHP>#2<MHP>#3<CMP>#4<MMP>...` (ErionMud)
- **Games (1):** ErionMud `ErionMud-UI.xml:3617`, `ErionUI 1.0.xml:148`.
- **Max: SAME-LINE** (fields #1/#2 = cur/max hp). **Zero-config? NO, but AUTO-INSTALLED** - the
package **auto-sends** `prompt #1%h#2%H#3%m#4%M...` to reconfigure the game prompt, warning
the user "This UI will change your prompt". Deletes the prompt line from main.
### Family J - numeric-suffix space-separated `<500hp 300mn 200mv 20420tnl>` + percent variant
- **Games (1):** Dark Mists `DarkMistsCompanion.xml:4984` (numeric, current-only, max from
score), plus a **percent variant** `<75%hp 60%mn 100%mv>` (`:5042`) and a regen-annotated
variant `<500hp(+25) ...>` (`:4951`). The percent variant is notable: already 0-100, **no max
needed for display** (converted to absolute only if a score-derived max exists, else warns).
### Family K - protocol-in-band (not a "prompt" but a per-tick line)
- GemStone/DragonRealms (Lich) `<progressBar id='health' value='N' text='..'/>` - value is a
**0-100 percentage**, max hardcoded (`LichConnect/package.xml:541`). Aardwolf `statmon`
`^{stats}(.*)$` positional array, cur/max **same-line** (`mag-mudlet-aardwolf-gui/
package.xml:889`). Included for completeness; these are per-game protocol packs (L3), not a
generic regex family.
### Family L/M - non-vitals prompts
- MUD2 bare `*` prompt (`MUDKIP_Mud2.xml:1823`) - carries no vitals; hp/stamina come from the
`fes`/`qs` commands (score-parse). CKMud `[Pl: N,NNN,NNN | ...]` (`CK/CK.xml:3486`) - PL shown
**with commas** (`[0-9,]+`) but real cur/max from MSDP. Both are reminders that a prompt may be
a combat-state signal, not a data source.
### 4.1 Prompt-family summary
| Family | Structure | Games | Max same-line? | Zero-config? | Generic-reader viability |
|---|---|---|---|---|---|
| A | `Nh, Nm, Ne, Nw` (IRE) | 3 (IRE) | No (score) | Prompt yes / max no | GMCP covers IRE better; skip |
| B | `<cur/maxhp ...>` (Avatar) | 1 | **Yes** | **No - user sets prompt** | Per-game pack only |
| C | `(cur/max)(cur/max)(cur/max)` | 1 | **Yes** | **No - user sets prompt** | Per-game pack only |
| D | `<name|curhp|maxhp|...>` (DSL) | 1 | **Yes** | **No - user sets prompt** | Per-game pack only |
| E | `** HP: cur/max SP: cur/max` | 1 | **Yes** | **No - user sets prompt** | Per-game pack only |
| F | `[HP:cur EP:cur]` (t2t) | 1 | No (hardcoded) | Yes (capture) | Cautionary - hardcoded max |
| G | `Hp: N Gp: N Xp: N` / brief `Hp: cur(max)` | 1 | Brief yes / verbose no | Yes | Configurable-parser model |
| H | `<Nhp Nsp Nst>` (MM/CoffeeMud) | 2 | No (gmcp/score) | Yes (capture) | GMCP covers these games |
| I | `#1..#2..` markers (ErionMud) | 1 | **Yes** | **No - auto-installed** | Per-game pack only |
| J | `<Nhp Nmn Nmv Ntnl>` + `%` variant | 1 | No (score) / percent needs none | Yes (capture) | Percent variant is clean |
**The load-bearing finding:** every prompt family that carries **cur AND max on one line**
(B, C, D, E, I) is a **non-default prompt the user must set or the package must auto-install**.
Every family that works on the **default prompt** (A, F, G-verbose, H, J-numeric) carries
**current values only** and needs a max from elsewhere. So there is no free lunch: a zero-config
generic prompt reader gets *current* values from default prompts but **cannot get max** without
either a bootstrap command or a percentage prompt. This directly shapes section 5 and 6.
---
## 5. The maxima problem (design-critical)
**212 of 451 mechanisms (47%) see only current values** (`none-current-only`) - though many of
those are non-scalar (affects, balance flags, gold, names, positions) that need no max anyway.
For the scalar vitals that DO drive gauges, the corpus uses six maxima strategies. Full
distribution: **none-current-only 212, gmcp-field 83, hardcoded 55, same-line 43,
score-command-parse 30, msdp-field 18, observed-max 8.**
### 5.1 gmcp-field (83) / msdp-field (18) - max is a sibling protocol field. **Best.**
Max arrives out-of-band alongside current, always fresh, no command sent. gmcp: all row-A games
plus the separate-node dialects (`char.maxstats`, `Char.Maxstats`). msdp: `HEALTH_MAX` etc
(Realms of Despair), `POWERLEVEL_MAX` (CKMud). **Failure modes:** only the dialect divergence of
section 2 (max may live in a different node/casing) - not a data-freshness problem. This is the
target the starter UI should prefer.
### 5.2 same-line cur/max (43) - `<100/120hp>`. **Best among triggers.**
Both values in one regex capture: Avatar (B), Diku (C), DSL (D), Nanny (E), ErionMud (I),
ROTD-brief, Aardwolf statmon, MUD2 `fes`/`qs`, and the `score` lines themselves. **Failure
modes:** requires the specific prompt (families B/C/D/E/I need user setup); `curhp` can be `?`
when blinded (DSL guards this).
### 5.3 score-command-parse (30) - bootstrap max from the `score` screen. **Common, hazardous.**
The default-prompt games (Family A, G-verbose, J) learn maxima by parsing a `score`/`sc`/`qsc`
screen: `^\| Health : *\d+/(\d+)` (`Achaea Fancy GUI 1.0.xml:37`), `^Health:\s+(\d+)/(\d+)\s+
Mana:...` (`achaea-rat-counter/package.xml:245`, `lusternia-fancy-gui-v2.xml:33`), `^You have
(%d+)/(%d+) hit, ...` (`DarkMistsCompanion.xml:5105`).
**Who auto-sends `score` (and when):**
- **On login:** `achaea-fancy-gui` ("auto-SENDS `score` on login so the max* score-parse
triggers fire", `Achaea Fancy GUI 1.0.xml:79`); `achaea-rat-counter` (`package.xml:285`);
`lusternia-fancy-gui` ("On login the package SENDS `score`; until score runs, gauges have no
maxima", `:114`); `MultiBot Core v1.02/v1.11` (SENDS `score` on login, `:24`).
- **On world-enter / periodically:** `DarkMistsCompanion` sends `score` on `dmapi.world.enter`
(`:6904`); `CK` sends `status` ~every 120s when not fighting (`:2422`); `MultiBot` sends `stat`
every 120s (anti-whisk); `MUDKIP_Mud2` sends `fes` on a 10s timer.
- **On demand only (no auto-send):** `DSL PNP 4` (user types `score`; package only auto-sends
`whoami` for name); `MumeSpellTimers` (elicited when the user types score/affects);
`xamm-for-coffeemud` (only via a `score` alias the user runs); `ErionMud` `SilentScoreCapture()`
sends `score` but between border-markers on demand.
**Failure modes (all three are real and observed):**
1. **Nil/zero max until bootstrap.** Gauges are blank or wrongly scaled until the first `score`
completes. `DarkMistsCompanion` explicitly logs a warning "if hpMax==1" and the percent-prompt
path can't convert to absolute (`:5042`).
2. **Stale after a level-up.** Max HP rises on level, but the cached score-max does not until the
next `score`. Dark Mists mitigates with a **`You gain X/Y hp...` level-up trigger** (same-line
cur/max, `:4892`); DSL parses "You raise a level!! You gain N hit points" (`:160`). Without
such a trigger the bar under-reads after every level.
3. **Sending commands without consent.** Auto-sending `score`/`status`/`fes` injects output into
the user's screen (some packages **gag** it, some don't) and traffic to the server the user
did not type. `CK` gags the status block; several IRE GUIs let the `score` screen scroll past
on login. **This is the anti-goal for base-ui** (section 6).
### 5.4 observed-max (8) - track the highest value seen. **Consent-free, imprecise.**
No command, no max field - the package remembers the largest current value it has witnessed and
uses that as the bar maximum. `DarkMistsCompanion` xp bar tracks `StatusBar.maxTnl` (`:8114`);
MUD2 `updateQs` uses observed-max for magic when the `qs` line omits it; XAMM tnl (`:2301`);
LichConnect roundTime/castTime. **Failure modes:** under-reads until the player has been seen at
full (a fresh character shows a "full" bar that is actually partial), and can over-read if the
true max ever drops (debuff, form change). But it is the **only strategy that needs neither a
protocol field nor an injected command** - which makes it the right default for a zero-config
generic reader (with a UX note that values calibrate as you play).
### 5.5 hardcoded (55) - a fixed constant. **Wrong by construction, avoid.**
`hpMAX=240` for Two Towers (`tab-chat-and-bars-for-t2t/package.xml`, three packages), alignment
max 1000 / tnl max 1333 (Avatar), align+10000 max 20000 (CoffeeMUD), LichConnect `<progressBar>`
value already a percentage so "max" is a nominal 100. Fine for genuinely bounded 0-100
percentages; **wrong for any real hp/mp** where the constant will not match the player's actual
max. The t2t packages are the clearest example of the failure: the gauge is simply incorrect for
any character whose max HP isn't 240.
### 5.6 Maxima strategy ranking for the starter UI
1. **gmcp-field / msdp-field** - fresh, consent-free, out-of-band. Use whenever available.
2. **same-line cur/max** - only when the game's *default* prompt provides it (rare) or via L3
per-game packs where the user opts into a prompt.
3. **observed-max** - the safe zero-config fallback for current-only default prompts and
percentage prompts, with a "calibrates as you play" UX note.
4. **score-command-parse** - powerful but requires auto-sending a command; **only behind explicit
user consent** in base-ui.
5. **hardcoded** - never for hp/mp; acceptable only for genuinely bounded 0-100 percentages.
---
## 6. Starter-UI gauge recommendation (`base-ui`)
A three-layer design mirroring the chat catalog. Layer 1 exists today (as a fixed-path reader);
the work is to **harden L1 with the dialect map**, add **L2 MSDP**, and add a **conservative L3**.
Guiding principle from the corpus: **read-only and additive - never gag the prompt, never
auto-send a command without consent.**
### Layer 1 - GMCP, hardened with the section-2 dialect map (biggest, cheapest win)
Replace the fixed `gmcp.Char.Vitals.hp/maxhp/mp/maxmp` reader with a **normaliser** that resolves,
in order, a small candidate table into one internal `{hp,maxhp,mp,maxmp,...}` shape:
```lua
-- resolve current/max across observed dialects, tonumber-coerced
local V = gmcp.Char and (gmcp.Char.Vitals or gmcp.Char.player) or gmcp.char and gmcp.char.vitals
local MAX = (gmcp.Char and gmcp.Char.Maxstats) or (gmcp.char and gmcp.char.maxstats)
hp = tonumber(V and V.hp)
maxhp = tonumber((V and (V.maxhp or V.maxHp)) or (MAX and (MAX.maxhp or MAX.maxHp)))
mp = tonumber(V and (V.mp or V.mana or V.sp or V.energy))
maxmp = tonumber((V and (V.maxmp or V.maxMana or V.maxsp or V.maxEnergy)) or (MAX and (MAX.maxmana or MAX.maxsp)))
-- nested cur/max (Federation 2): V.stamina.cur / V.stamina.max
```
Concretely it must handle: (1) **lowercase** `gmcp.char.vitals` (Edge of Midnight, Materia Magica,
CoffeeMUD, fed2); (2) **max in a separate node** `char.maxstats`/`Char.Maxstats` (Materia Magica,
Edge of Midnight, Icesus); (3) **mana key** `.mp|.mana|.sp|.energy`; (4) **camelCase** `maxHp`
(Medievia, LOTJ); (5) **string values** -> always `tonumber` (IRE); (6) **nested cur/max**
(fed2). Leave the alternate-namespace shapes (Procedural Realms `Char.player`, group arrays) to
L3 - they are single-game.
This single change takes GMCP coverage from **3 games (IRE core) to ~12-14 games** with no new
protocol, no triggers, and inherent safety (out-of-band, nothing to gag).
### Layer 2 - MSDP defensive subscription (small, honest, additive)
On connect, if MSDP negotiates, send a **standardized REPORT** and wire the standard pairs:
```lua
-- on sysConnectionEvent, after MSDP is enabled
sendMSDP("REPORT", "HEALTH","HEALTH_MAX","MANA","MANA_MAX","MOVEMENT","MOVEMENT_MAX")
-- handlers: msdp.HEALTH/HEALTH_MAX -> hp gauge; msdp.MANA/MANA_MAX -> mp gauge (tonumber)
```
**Honest scope:** the corpus proves MSDP vitals for only 3 games, and one (CKMud) renames HEALTH
to POWERLEVEL - so this reaches the **standardized-MSDP** slice (Realms of Despair and the
SMAUG/ROM ecosystem that uses those names) but not renamers. Subscribing costs one sub-negotiation
and is harmless if the game ignores it, so it is worth shipping as a **zero-config second layer** -
just documented as "covers games using standard MSDP names", not universal. Re-subscribe on
`UPDATE_EPOCH` staleness (CKMud's idiom) if reliability warrants.
### Layer 3 - generic prompt reader + per-game packs (conservative, consent-aware)
**3a. Generic default-prompt reader (current-only + observed-max).** Ship a *few* anchored,
low-FP prompt patterns that work on **default** prompts and feed gauges with **observed-max**
scaling (never auto-`score`):
- **Percentage prompts are the cleanest** and should be first-class: `(\d+)%hp` style
(Dark Mists percent variant `:5042`), Aardwolf/`\d+%`, `<progressBar value>` percentages.
A 0-100 value needs **no max at all** - paint it directly.
- **Current-only numeric default prompts** (IRE `Nh, Nm, Ne, Nw`; Dark Mists `<Nhp Nmn Nmv>`;
ROTD `Hp: N ...`): capture current, scale the bar with **observed-max**, and show a small
"values calibrate as you play" hint. Do **not** auto-send `score` to resolve max.
**3b. Per-game packs as data** (keyed like Mudlet's `TGameDetails` registry, loaded when the
profile matches). These carry the same-line-max and prompt-config families that are unsafe to
generalise. Corpus-sourced, field-tested packs ready to lift verbatim:
| Game | Source package | What it provides | Note |
|---|---|---|---|
| Achaea/Aetolia/Lusternia/Imperian (IRE) | `Achaean System`, `earthshaker-*`, `lusternia-fancy-gui` | GMCP `Char.Vitals` hp/mp/ep/wp + `score` maxima parsers | L1 covers hp/mp; pack adds ep/wp + score-max |
| Realms of Despair | `realms-of-despair-ui` | standard MSDP REPORT + gauges | L2 covers; pack adds enemy/xp |
| CKMud | `CK` | MSDP `POWERLEVEL/KI/FATIGUE` renamed pairs | renamer - needs the map |
| Avatar MUD | `avatar-mud-package` | `<cur/maxhp ...>` same-line prompt (requires prompt set) | opt-in prompt |
| DSL | `DSL PNP 4` | pipe-delimited `<...|curhp|maxhp|...>` (requires prompt set) | opt-in prompt |
| NannyMUD | `nannymud-starter-module` | `** HP: cur/max SP: cur/max` (requires prompt set) | opt-in prompt |
| Two Towers | `importThis`/`tab-chat-and-bars-for-t2t` | `[HP:cur EP:cur]` (hardcoded max - fix to observed) | replace hardcoded 240 |
| Realms of the Dragon | `ROTD_GUI` | `Hp: N Gp: N Xp: N` + brief `Hp: cur(max)` + configurable parser | configurable-parser model |
| Materia Magica | `materia-magica-gui`, `mm_package` | `<Nhp Nsp Nst>` prompt + `char.maxstats` GMCP | L1 covers with dialect map |
| Aardwolf | `mag-mudlet-aardwolf-gui` | `statmon {stats}` positional cur/max | protocol pack |
| GemStone/DragonRealms | `LichConnect` | Stormfront `<progressBar>` percentages | protocol pack |
| CoffeeMUD | `cofudlet`, `xamm-for-coffeemud` | lowercase GMCP + `<100Hp 50m 30mv>` prompt | L1 covers GMCP |
| Federation 2 | `fed2ui`, `fed2-tools` | nested `char.vitals.stamina.cur/.max` | L1 nested-table path |
| ErionMud | `ErionMud-UI` | `#N` marker prompt (auto-installs prompt) | opt-in, warns user |
### Anti-goals (learned from the corpus)
1. **Do not auto-send `score`/`status`/`fes` to bootstrap maxima without explicit user consent.**
A third of the score-parse packages inject this traffic on login (`achaea-fancy-gui`,
`lusternia-fancy-gui`, `MultiBot`, `DarkMistsCompanion`); some gag the output, some let it
scroll past. For a starter UI that runs for *everyone*, auto-sending commands to the game on
the user's behalf is surprising and undesirable. **Prefer observed-max + a "calibrates as you
play" note; offer score-bootstrap only as an explicit opt-in.**
2. **Do not gag/`deleteLine` the prompt.** Avatar, ErionMud, XAMM delete the prompt line; that
breaks logging and prompt-end detection (same hazard the chat catalog flagged). Read the
prompt additively.
3. **Do not hardcode a max for hp/mp.** The Two Towers packages (`hpMAX=240`) produce a gauge
that is simply wrong for any other character. Hardcoded maxima are acceptable *only* for
genuinely bounded 0-100 percentages.
4. **Do not assume canonical GMCP casing/shape.** The naive `gmcp.Char.Vitals.hp/maxhp` reader
matches only the IRE core; every non-IRE game diverges (section 2). Normalise before painting.
5. **Do not require the user to reconfigure their in-game prompt in the generic path.** Families
B/C/D/E/I all demand a specific prompt; that is per-game opt-in (L3), never the default.
6. **Do not build per-game regex sets inside base-ui.** Keep them as external data (L3), so
adding a game is a data PR, not a code change - same conclusion as the chat catalog.
---
## Appendix: honest coverage limits
- **63 games/packages is a real but uneven sample.** IRE is over-represented at the package
level; most non-IRE games are a single package = one author's field-tested patterns. Treat
those as strong starting points, not validated specs.
- **The vitals GMCP story is genuinely broader than chat's** (14 non-IRE games vs an
IRE-dominated chat corpus) - this is the corpus's clearest signal and the strongest argument
for hardening L1 rather than treating GMCP as "IRE only".
- **MSDP is thin (3 games).** Ship a defensive standardized REPORT, but do not advertise coverage
the corpus does not support; renamers (CKMud) still need per-game maps.
- **The maxima problem has no universal zero-config solution.** Protocol fields solve it when
present; otherwise every default prompt is current-only and the only consent-free option is
observed-max (imprecise) or percentage prompts (clean but game-specific). Auto-`score` is
effective but is an anti-goal for a default-on starter UI.
- **hp and mp are the only near-universal scalars.** Everything else (movement, willpower, ego,
endurance, psp, stamina, blood, fury, enemy-hp, affects) is game-specific and belongs in the
per-game map, not the generic gauges.

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@
#include <QString>
#include <QList>
#include <QStringList>
struct GameDetail
{
@ -33,6 +34,11 @@ struct GameDetail
QString websiteInfo;
QString icon;
QString description;
// the game's bundled loader installs the game's own full interface, so
// the generic starter UI is not preinstalled for it:
bool providesOwnUi = false;
// other hostnames the game is reachable under:
QStringList alternateHostUrls;
};
class TGameDetails
@ -59,6 +65,16 @@ public:
return result;
}
inline static bool gameProvidesOwnUi(const QString& hostUrl)
{
for (const auto& game : scmDefaultGames) {
if (game.providesOwnUi && (!game.hostUrl.compare(hostUrl, Qt::CaseInsensitive) || game.alternateHostUrls.contains(hostUrl, Qt::CaseInsensitive))) {
return true;
}
}
return false;
}
// clang-format off
// games are to be added here in alphabetical order, except the tutorial which should be first
inline static const QList<GameDetail> scmDefaultGames = {
@ -602,7 +618,8 @@ qsl("<a href='https://abandonedrealms.com'>Website</a><br>"
"is supportive of new players - unforgiving though our world may be. Join us for a "
"real challenge and real rewards: adrenalin-pumping battles, memorable quests run "
"by our volunteer immortal staff, and stories that will stick with you for a "
"lifetime.")},
"lifetime."),
true}, // CF-loader installs CFGUI
{qsl("Cleft of Dimensions"),
qsl("cleftofdimensions.net"),
@ -700,7 +717,9 @@ qsl("<a href='https://abandonedrealms.com'>Website</a><br>"
"\n\n"
"Unsere freundliche Spielerschaft hilft Dir gerne bei Deinen ersten Schritten."
"\n\n"
"Spiel jetzt oder nie!")},
"Spiel jetzt oder nie!"),
true, // mg-loader installs MorgenGrauen's own interface
{qsl("mg.mud.de"), qsl("mg.morgengrauen.info"), qsl("morgengrauen.info")}},
{qsl("Infinity"),
qsl("infinitymud.com"),
@ -743,7 +762,8 @@ qsl("<a href='https://abandonedrealms.com'>Website</a><br>"
" Weather, storms, wind, fire, floods, disease, even asteroids. This may be text "
"but it is the most dynamic game ever attempted. The wind affects the ships, where "
"fire spreads, and even how some critters smell you if you are upwind from them.\n\n"
"Do you dare enter?")},
"Do you dare enter?"),
true}, // MedBootstrap installs MedUI
{qsl("Dragonfire MUD"),
qsl("dragonfiremud.com"),
@ -808,7 +828,8 @@ qsl("<a href='https://abandonedrealms.com'>Website</a><br>"
"combat, explore the frozen Valley of Aegic, and earn your place in "
"player-driven provinces."
"\n\n"
"Old-school depth. Modern access. New players welcome.")},
"Old-school depth. Modern access. New players welcome."),
true}, // icesus-loader installs Icesus' own interface
};
// clang-format on
};

View file

@ -1568,6 +1568,15 @@ void cTelnet::slot_replyFinished(QNetworkReply* reply)
packageName.remove(QLatin1Char('/'));
packageName.remove(QLatin1Char('\\'));
mpHost->mServerGUI_Package_name = packageName;
// Let scripts (e.g. the preinstalled starter UI) react to the game
// having supplied its own interface:
TEvent event{};
event.mArgumentList.append(qsl("sysServerGuiInstalled"));
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
event.mArgumentList.append(packageName);
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
mpHost->raiseEvent(event);
}
}

View file

@ -0,0 +1,27 @@
-- After editing this file or mudlet-base-ui.xml, rebuild mudlet-base-ui.mpackage:
-- it is a zip of config.lua + mudlet-base-ui.xml + .mudlet/Icon/mudlet.png.
mpackage = [[mudlet-base-ui]]
author = [[Mudlet Makers]]
icon = [[mudlet.png]]
title = [[A starter interface with health bars, map and chat, built from what your game provides.]]
description = [[# Mudlet base UI
A modest starter interface for players new to Mudlet: an adjustable dock
with your map, tabbed chat (All/Tells/Channels with unread counters) and
health, mana, movement and experience gauges - built only from data your
game actually provides (GMCP, MSDP, or recognisable prompt and score
lines). Nothing appears until the game sends something to show.
Commands:
```
> baseui -- show status and options
> baseui hide -- remove the interface (remembered between sessions)
> baseui show -- bring it back
```
When a game installs an interface of its own, this one quietly stands
aside - `baseui show` brings it back if you prefer it.
]]
version = [[1.0.0]]
created = "2026-07-25T12:00:00+00:00"

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1457,6 +1457,412 @@ describe("Tests UI functions", function()
end)
end)
-- BaseUI.parseVitalsLine is the pure parser behind the starter UI's
-- prompt/score vitals fallback (the base-ui package installs into fresh
-- profiles, including the self-test one)
describe("Test the functionality of BaseUI.parseVitalsLine", function()
local parserAvailable = type(BaseUI) == "table" and type(BaseUI.parseVitalsLine) == "function"
if not parserAvailable then
it("needs the base UI package installed", function()
pending("BaseUI.parseVitalsLine is unavailable in this profile")
end)
return
end
local function reading(hits, stat, kind)
for _, hit in ipairs(hits) do
if hit.stat == stat and (kind == nil or hit.kind == kind) then
return hit
end
end
end
local function kindCount(hits, kind)
local count = 0
for _, hit in ipairs(hits) do
if hit.kind == kind then
count = count + 1
end
end
return count
end
it("should parse a cur/max prompt with the labels after the numbers", function()
local hits = BaseUI.parseVitalsLine("<523/600hp 210/250m 80/100mv>")
local hp = reading(hits, "hp", "curmax")
assert.is_not_nil(hp)
assert.are.equal(523, hp.current)
assert.are.equal(600, hp.max)
local mp = reading(hits, "mp", "curmax")
assert.is_not_nil(mp)
assert.are.equal(210, mp.current)
assert.are.equal(250, mp.max)
local mv = reading(hits, "mv", "curmax")
assert.is_not_nil(mv)
assert.are.equal(80, mv.current)
assert.are.equal(100, mv.max)
assert.are.equal(0, kindCount(hits, "bare"))
end)
it("should parse a cur/max prompt with the labels first", function()
local hits = BaseUI.parseVitalsLine("HP: 523/600 MP: 210/250")
local hp = reading(hits, "hp", "curmax")
assert.is_not_nil(hp)
assert.are.equal(523, hp.current)
assert.are.equal(600, hp.max)
local mp = reading(hits, "mp", "curmax")
assert.is_not_nil(mp)
assert.are.equal(210, mp.current)
assert.are.equal(250, mp.max)
end)
it("should parse labelled percentages without needing a maximum", function()
local hits = BaseUI.parseVitalsLine("<87%hp 80%m>")
local hp = reading(hits, "hp", "percent")
assert.is_not_nil(hp)
assert.are.equal(87, hp.percent)
local mp = reading(hits, "mp", "percent")
assert.is_not_nil(mp)
assert.are.equal(80, mp.percent)
assert.are.equal(0, kindCount(hits, "curmax"))
assert.are.equal(0, kindCount(hits, "bare"))
end)
it("should parse score screen lines", function()
local hp = reading(BaseUI.parseVitalsLine("Health : 523/600"), "hp", "curmax")
assert.is_not_nil(hp)
assert.are.equal(523, hp.current)
assert.are.equal(600, hp.max)
local mp = reading(BaseUI.parseVitalsLine("Mana : 210/250"), "mp", "curmax")
assert.is_not_nil(mp)
assert.are.equal(210, mp.current)
assert.are.equal(250, mp.max)
local mv = reading(BaseUI.parseVitalsLine("Moves : 80/100"), "mv", "curmax")
assert.is_not_nil(mv)
assert.are.equal(80, mv.current)
assert.are.equal(100, mv.max)
local sentence = reading(BaseUI.parseVitalsLine("You have 100/120 hit points left."), "hp", "curmax")
assert.is_not_nil(sentence)
assert.are.equal(100, sentence.current)
assert.are.equal(120, sentence.max)
end)
it("should classify current-only prompts as bare, never self-sufficient", function()
local hits = BaseUI.parseVitalsLine("<523hp 210m 80mv>")
local hp = reading(hits, "hp", "bare")
assert.is_not_nil(hp)
assert.are.equal(523, hp.current)
assert.is_nil(hp.max)
assert.is_not_nil(reading(hits, "mp", "bare"))
assert.is_not_nil(reading(hits, "mv", "bare"))
assert.are.equal(0, kindCount(hits, "curmax"))
assert.are.equal(0, kindCount(hits, "percent"))
end)
it("should never mistake a self-sufficient line for a bare one", function()
local hits = BaseUI.parseVitalsLine("523/600hp")
assert.is_not_nil(reading(hits, "hp", "curmax"))
assert.are.equal(0, kindCount(hits, "bare"))
end)
it("should yield nothing for chat lines that merely mention vitals", function()
assert.are.same({}, BaseUI.parseVitalsLine("Bob says, 'I am somehow alive at 100/120 hp'"))
assert.are.same({}, BaseUI.parseVitalsLine("[chat] Ann: brags about her 100/120 hp"))
end)
it("should still read lines whose bracket tag is not a known channel", function()
assert.is_not_nil(reading(BaseUI.parseVitalsLine("[combat] 100/120 hp"), "hp", "curmax"))
end)
it("should yield nothing for unlabelled or unrelated numbers", function()
assert.are.same({}, BaseUI.parseVitalsLine("You see 100/120 on the door"))
assert.are.same({}, BaseUI.parseVitalsLine("There are 523 hippos in the river"))
assert.are.same({}, BaseUI.parseVitalsLine("You are carrying 210 mushrooms"))
end)
it("should reject a zero maximum and percentages above 100", function()
assert.are.same({}, BaseUI.parseVitalsLine("0/0 hp"))
assert.are.same({}, BaseUI.parseVitalsLine("150%hp"))
end)
it("should allow overheal (current above maximum)", function()
local hp = reading(BaseUI.parseVitalsLine("750/600hp"), "hp", "curmax")
assert.is_not_nil(hp)
assert.are.equal(750, hp.current)
assert.are.equal(600, hp.max)
end)
it("should not let one stat's numbers be claimed by the next label", function()
local hits = BaseUI.parseVitalsLine("HP: 523/600 MP:")
local hp = reading(hits, "hp", "curmax")
assert.is_not_nil(hp)
assert.are.equal(523, hp.current)
assert.is_nil(reading(hits, "mp"))
end)
it("should keep aligned-table padding from leaking one stat's numbers to the next", function()
local hits = BaseUI.parseVitalsLine("Health: 3600/3600 Mana: 3400/3400")
local mp = reading(hits, "mp", "curmax")
assert.is_not_nil(mp)
assert.are.equal(3400, mp.current)
assert.are.equal(3400, mp.max)
end)
-- the score-screen corpus: shapes from the major codebase families. A
-- score may only ever be shown once, so every expected reading must come
-- from an ungated (trusted-on-first-sight) shape - a gated reading would
-- never fire on a screen the recurrence gate has not seen three times
describe("score screens across codebase families", function()
local function firstSightReading(hits, stat, kind)
for _, hit in ipairs(hits) do
if hit.stat == stat and hit.kind == kind and not hit.gated then
return hit
end
end
end
local screens = {
-- ROM 2.4 act_info.c do_score, verbatim format
{ name = "a ROM 2.4 score sentence",
line = "You have 100/100 hit, 100/100 mana, 100/100 movement.",
expect = { hp = { 100, 100 }, mp = { 100, 100 }, mv = { 100, 100 } } },
-- Merc 2.1 appends practices to the same sentence
{ name = "a Merc 2.1 score sentence",
line = "You have 100/100 hit, 90/90 mana, 80/100 movement, 12 practices.",
expect = { hp = { 100, 100 }, mp = { 90, 90 }, mv = { 80, 100 } } },
-- DikuMUD/CircleMUD/tbaMUD write current(max)
{ name = "a Diku/Circle/tbaMUD score sentence",
line = "You have 20(20) hit, 100(100) mana and 82(82) movement points.",
expect = { hp = { 20, 20 }, mp = { 100, 100 }, mv = { 82, 82 } } },
-- SMAUG 1.4a dashboard rows: "current of max" behind unrelated cells
{ name = "a SMAUG hitpoints row",
line = "PRACT: 005 Hitpoints: 90 of 90 Pager: ( ) 24 AutoExit(X)",
expect = { hp = { 90, 90 } } },
{ name = "a SMAUG mana row",
line = "XP : 123456 Mana: 75 of 90 MKills: 00012 AutoLoot (X)",
expect = { mp = { 75, 90 } } },
{ name = "a SMAUG move row (comma-grouped gold in front)",
line = "GOLD : 1,234,567 Move: 80 of 90 Mdeaths: 00000 AutoSac ( )",
expect = { mv = { 80, 90 } } },
-- SWRFUSS puts three "of" pairs on one line
{ name = "a SWR-style of-separated line",
line = "Hit Points: 100 of 100 Move: 90 of 100 Force: 100 of 100",
expect = { hp = { 100, 100 }, mv = { 90, 100 } } },
-- Achaea's bordered vitals block (mana above max is real overheal)
{ name = "an Achaea health row",
line = "| Health : 2594/2594 Willpower: 13730/13730 Strength : 12 Intelligence: 13 |",
expect = { hp = { 2594, 2594 } } },
{ name = "an Achaea mana/endurance row",
line = "| Mana : 3671/2966 Endurance: 11600/11870 Dexterity: 12 Constitution: 11 |",
expect = { mp = { 3671, 2966 }, mv = { 11600, 11870 } } },
-- Aetolia's cells sit behind interior pipes after a non-vital cell
{ name = "an Aetolia bordered row",
line = "| Race: Undead Atavian | Health: 4252/4252 | Endurance: 19950/19950 |",
expect = { hp = { 4252, 4252 }, mv = { 19950, 19950 } } },
-- Aardwolf brackets its values inside a full grid
{ name = "an Aardwolf hit row",
line = "| Hit : [ 168/168 ] | Hitroll : [ 27 ] | Weight : 40 of 135 |",
expect = { hp = { 168, 168 } } },
{ name = "an Aardwolf mana row",
line = "| Mana : [ 160/160 ] | Damroll : [ 14 ] | Items : 23 of 105 |",
expect = { mp = { 160, 160 } } },
{ name = "an Aardwolf moves row",
line = "| Moves : [ 564/564 ] | Wimpy : [ 18 ] | Pos : Standing |",
expect = { mv = { 564, 564 } } },
-- Discworld brief: current(max), no space before the paren
{ name = "a Discworld brief score line",
line = "Hp: 2331(2331) Gp: 433(459) Xp: 1143225 Burden: 21%",
expect = { hp = { 2331, 2331 } } },
-- Discworld verbose: current (max) with a space
{ name = "a Discworld verbose score sentence",
line = "You have 1110 (1110) hit points, 167 (167) guild points, 2 (684) quest points, "
.. "7 (1063) achievement points and 81 (81) social points.",
expect = { hp = { 1110, 1110 } } },
-- LPMud 2.4.5 writes current, label, then the max
{ name = "an LPMud 2.4.5 score sentence",
line = "You have 123 experience points, 45 gold coins, 50 hit points(50).",
expect = { hp = { 50, 50 } } },
-- AFKMud's first row opens with a "Label: number" cell
{ name = "an AFKMud hitpoints row",
line = "Level: 5 HitPoints: 100/ 100 Pager ( )",
expect = { hp = { 100, 100 } } },
{ name = "an IRE-style aligned score table",
line = "Health: 3600/3600 Mana: 3400/3400",
expect = { hp = { 3600, 3600 }, mp = { 3400, 3400 } } },
{ name = "a bordered row whose first cell is not a vital",
line = "| Level: 201 Hit Points: 500/500 Moves: 1000/1000 |",
expect = { hp = { 500, 500 }, mv = { 1000, 1000 } } },
{ name = "thousands separators",
line = "Hit Points: 12,345/23,456",
expect = { hp = { 12345, 23456 } } },
{ name = "a spell points row",
line = "Spell Points: 90/95",
expect = { mp = { 90, 95 } } },
{ name = "a magic row",
line = "Magic: 90/95",
expect = { mp = { 90, 95 } } },
{ name = "a movement row",
line = "Movement: 80/100",
expect = { mv = { 80, 100 } } },
{ name = "a stamina row",
line = "Stamina: 80/100",
expect = { mv = { 80, 100 } } },
{ name = "an experience row",
line = "Experience: 1000/5000",
expect = { xp = { 1000, 5000 } } },
}
for _, screen in ipairs(screens) do
it("should read " .. screen.name .. " on first sight", function()
local hits = BaseUI.parseVitalsLine(screen.line)
for stat, pair in pairs(screen.expect) do
local hit = firstSightReading(hits, stat, "curmax")
assert.is_not_nil(hit, screen.name .. ": no ungated " .. stat .. " reading")
assert.are.equal(pair[1], hit.current)
assert.are.equal(pair[2], hit.max)
end
end)
end
it("should not read guild points or bare xp from an LPMud row", function()
local hits = BaseUI.parseVitalsLine("Hp: 143 (167) Gp: 240 (240) Xp: 267000")
assert.is_nil(reading(hits, "mp"))
assert.is_nil(reading(hits, "xp"))
end)
-- rows with no structural anchor at all (AFKMud's "Race : Human
-- Mana : 1000/1000") only parse as windowed readings, which
-- BaseUI.onVitalsLine trusts solely inside the short window after a
-- "score" command actually went to the game
it("should mark anchorless labelled pairs as windowed, not trusted", function()
local hits = BaseUI.parseVitalsLine("Race : Human Mana : 1000/ 1000 Autoexit (X)")
local found
for _, hit in ipairs(hits) do
if hit.stat == "mp" and hit.kind == "curmax" then
found = found or hit
end
end
assert.is_not_nil(found)
assert.is_true(found.windowed == true)
assert.are.equal(1000, found.current)
assert.are.equal(1000, found.max)
end)
it("should open the score window when a score command goes out", function()
local saved = BaseUI.scoreWindowUntil
BaseUI.scoreWindowUntil = nil
assert.is_false(BaseUI.scoreWindowOpen())
BaseUI.noteCommandSent("sysDataSendRequest", "look")
assert.is_false(BaseUI.scoreWindowOpen())
BaseUI.noteCommandSent("sysDataSendRequest", "score")
assert.is_true(BaseUI.scoreWindowOpen())
BaseUI.scoreWindowUntil = getEpoch() - 1
assert.is_false(BaseUI.scoreWindowOpen())
BaseUI.scoreWindowUntil = saved
end)
end)
-- the score shapes run always-on against everything the game prints, so
-- ordinary output must never produce a first-sight-trusted reading.
-- gated readings are fine (they need the recurrence gate first) and so
-- are windowed ones (inert outside the short post-"score" window, which
-- is their entire safety mechanism - the shapes themselves are
-- deliberately anchorless)
describe("ungated false-positive safety", function()
local prose = {
"You have collected 5/6 mana crystals for the ritual.",
"You have 5/6 mana potions in your bag.",
"You have 3(4) quest tokens.",
"You have 100 gold and 5/6 keys.",
"You have 3/4 of the map explored.",
"Health potions line the shelves, 3/4 full.",
"Mana is the lifeblood of spellcasters, see HELP MANA.",
"| [newbie] Zork: my hp is 100/120 lol |",
"| 12 | a healing potion | 100/120 gold |",
"| Players: 15/20 |",
"| Score: 4500/9000 |",
"| HP regen: 5/tick class bonus |",
"The scoreboard shows 12/15 wins for your team.",
"Uptime: 12/24 hours since last reboot.",
"Quests completed: 37/50",
"You get 2,500 gold coins from the corpse.",
}
for _, line in ipairs(prose) do
it("should not trust on first sight: " .. line, function()
for _, hit in ipairs(BaseUI.parseVitalsLine(line)) do
assert.is_true(hit.gated == true or hit.windowed == true,
string.format("first-sight %s (%s) reading from prose", hit.stat, hit.kind))
end
end)
end
end)
it("should reject nonsense readings instead of painting broken gauges", function()
assert.are.same({}, BaseUI.parseVitalsLine("Health: 0/0"))
-- a wildly overhealed current is a misread, not overheal
assert.is_nil(reading(BaseUI.parseVitalsLine("Health: 90000/2"), "hp"))
-- absurd magnitudes are ids or timestamps, never vitals
assert.are.same({}, BaseUI.parseVitalsLine("Health: 1234567890123/9999999999999"))
end)
end)
-- when a game installs its own interface (a Client.GUI package), the
-- starter UI stands aside rather than fight it for screen space
describe("Test the starter UI standing aside for a game's own interface", function()
local baseUiAvailable = type(BaseUI) == "table" and type(BaseUI.standAside) == "function"
if not baseUiAvailable then
it("needs the base UI package installed", function()
pending("BaseUI.standAside is unavailable in this profile")
end)
return
end
local savedSettings
before_each(function()
savedSettings = table.deepcopy(BaseUI.settings)
end)
after_each(function()
BaseUI.settings = savedSettings
BaseUI.saveSettings()
BaseUI.createChatTriggers()
BaseUI.createVitalsTriggers()
end)
it("should stand aside when the game installs its own GUI", function()
BaseUI.standAside("sysServerGuiInstalled", "SomeGameUI")
assert.are.equal("SomeGameUI", BaseUI.settings.standingAside)
assert.is_true(BaseUI.dormant())
end)
it("should retire its capture triggers while standing aside", function()
BaseUI.standAside("sysServerGuiInstalled", "SomeGameUI")
assert.is_nil(next(BaseUI.chatTriggerIds))
assert.is_nil(next(BaseUI.vitalsTriggerIds))
BaseUI.createChatTriggers()
BaseUI.createVitalsTriggers()
assert.is_nil(next(BaseUI.chatTriggerIds))
assert.is_nil(next(BaseUI.vitalsTriggerIds))
end)
it("should come back when the player asks for it", function()
BaseUI.standAside("sysServerGuiInstalled", "SomeGameUI")
BaseUI.show()
assert.is_nil(BaseUI.settings.standingAside)
assert.is_false(BaseUI.dormant())
end)
it("should ignore uninstalls of unrelated packages", function()
BaseUI.standAside("sysServerGuiInstalled", "SomeGameUI")
BaseUI.serverGuiRemoved("sysUninstallPackage", "SomethingElse")
assert.are.equal("SomeGameUI", BaseUI.settings.standingAside)
end)
end)
describe("tempButtonToolbar and tempButton return values", function()
-- unique names as the items cannot be deleted, and would collide on re-runs
-- against the same profile otherwise

View file

@ -7313,6 +7313,17 @@ void mudlet::setupPreInstallPackages(const QString& gameUrl, const QString& prof
mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-lua/lua/generic-mapper/generic_mapper.mpackage"));
}
// A modest starter UI that adapts to whatever any game provides, only for
// players new to Mudlet - veterans will have their own layouts already.
// Games whose bundled loader above fetches the game's own full interface
// (flagged in TGameDetails) are skipped: the starter UI would only fight
// it for the same screen space. Games that push a GUI via Client.GUI at
// connect time are handled at runtime instead - the starter UI stands
// aside when one installs.
if (!mudlet::self()->experiencedMudletPlayer() && !TGameDetails::gameProvidesOwnUi(gameUrl)) {
mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage"));
}
// Don't play tutorial for every connection to localhost. There are legit other reasons to connect there.
if (profileName == qsl("Mudlet Tutorial") && gameUrl == qsl("localhost")) {
mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-tutorial.mpackage"));

View file

@ -223,6 +223,7 @@
<file>icesus-loader.xml</file>
<file>lua-function-list.json</file>
<file>mg-loader.xml</file>
<file>mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage</file>
<file>mudlet-lua/lua/generic-mapper/generic_mapper.mpackage</file>
<file>mudlet-lua/lua/gui-drop/gui-drop.mpackage</file>
<file>enable-accessibility.mpackage</file>

View file

@ -157,14 +157,18 @@ private slots:
// -----------------------------------------------------------------------
void test_tempTriggersRemovedAfterReset() {
// preinstalled packages (the starter UI) register their own temp triggers
// and re-register them when the profile comes back, so the count after a
// reset returns to that baseline rather than zero
const int baseline = countTempTriggers();
int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("test_pattern"),
qsl(""), -1);
QVERIFY(id > 0);
QVERIFY(countTempTriggers() > 0);
QVERIFY(countTempTriggers() > baseline);
performReset();
QCOMPARE(countTempTriggers(), 0);
QCOMPARE(countTempTriggers(), baseline);
}
void test_tempAliasesRemovedAfterReset() {
@ -512,6 +516,7 @@ private slots:
// -----------------------------------------------------------------------
void test_phase2RunsDeferredNotImmediate() {
const int baseline = countTempTriggers();
int triggerId = mpHost->mLuaInterpreter.startTempTrigger(
qsl("deferred_test"), qsl(""), -1);
QVERIFY(triggerId > 0);
@ -521,14 +526,14 @@ private slots:
// Phase2 has NOT run yet
QVERIFY2(mpHost->mResetProfile,
"mResetProfile should be true before processEvents");
QVERIFY2(countTempTriggers() > 0,
QVERIFY2(countTempTriggers() > baseline,
"Temp triggers should still exist before processEvents");
QCoreApplication::processEvents();
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false after processEvents");
QCOMPARE(countTempTriggers(), 0);
QCOMPARE(countTempTriggers(), baseline);
}
// -----------------------------------------------------------------------
@ -636,6 +641,7 @@ private slots:
// -----------------------------------------------------------------------
void test_doubleResetIsGuarded() {
const int baseline = countTempTriggers();
mpHost->mLuaInterpreter.startTempTrigger(qsl("double_reset_test"), qsl(""),
-1);
@ -658,7 +664,7 @@ private slots:
lua_pop(afterL, 1);
QVERIFY2(!mpHost->mResetProfile,
"mResetProfile should be false after reset");
QCOMPARE(countTempTriggers(), 0);
QCOMPARE(countTempTriggers(), baseline);
}
// -----------------------------------------------------------------------