mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
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:
parent
dabd95df36
commit
69cd06b1c2
14 changed files with 14299 additions and 9 deletions
20
docs/package-capture-audits/README.md
Normal file
20
docs/package-capture-audits/README.md
Normal 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.
|
||||
541
docs/package-capture-audits/chat-capture-catalog.md
Normal file
541
docs/package-capture-audits/chat-capture-catalog.md
Normal 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.
|
||||
4803
docs/package-capture-audits/chat-capture-findings.json
Normal file
4803
docs/package-capture-audits/chat-capture-findings.json
Normal file
File diff suppressed because it is too large
Load diff
526
docs/package-capture-audits/vitals-capture-catalog.md
Normal file
526
docs/package-capture-audits/vitals-capture-catalog.md
Normal 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.
|
||||
6618
docs/package-capture-audits/vitals-capture-findings.json
Normal file
6618
docs/package-capture-audits/vitals-capture-findings.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
src/mudlet-lua/lua/base-ui/config.lua
Normal file
27
src/mudlet-lua/lua/base-ui/config.lua
Normal 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"
|
||||
BIN
src/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage
Normal file
BIN
src/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage
Normal file
Binary file not shown.
1301
src/mudlet-lua/lua/base-ui/mudlet-base-ui.xml
Normal file
1301
src/mudlet-lua/lua/base-ui/mudlet-base-ui.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue