diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd450e6..a01c4a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,96 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] - A crash now leaves its stack in `logs/client-diagnostics-*.log`, in the window and the terminal alike, along with errors the UI framework used to swallow without a trace. +- A `scripts` field on each trigger set in Worlds & Characters (F5), and Ctrl+P ▸ *Reload scripts*, which + re-reads the focused character's files. +- GMCP games now send their data. The client introduces itself (`Core.Hello`) and asks for the common + modules (`Core.Supports.Set`: Char, Char.Skills, Char.Items, Room, Comm.Channel) once GMCP is agreed, and + asks an MSDP game to report the variables the status row reads. Worlds & Characters (F5) has a + `gmcp modules` field for a world that needs more, such as `IRE.Rift 1`. +- Health, mana and movement on the status row, read from GMCP or MSDP. A meter shows only once its value + arrives, keeps its width as the numbers change, and turns to the warning colour below a quarter. Which + values the row shows is the `meters` setting in Text & ANSI (F7). +- Ctrl+P ▸ *Show game data* lists every GMCP package and MSDP variable the focused connection holds, and + shows the selected one in full. +- Lua scripts can read and send game data: `gmcp.get('Char.Vitals')`, `msdp.get('HEALTH')` and + `gmcp.send('Char.Skills.Get', { group = 'combat' })`. +- Worlds & Characters (F5) shows three settings that could only be changed in `config.json`: a world's + `content` (`auto`, `ansi`, `mxp` or `pueblo`; `ansi` keeps a world on plain colour even when the server + offers MXP), its `emoji` substitution (off, emoticons, shortcodes or both), and a character's + `disconnect` commands. +- The client records a map of each game as you walk it, from GMCP `Room.Info` (Iron Realms, Aardwolf and + MUME shapes) or, for games that send no room data, from the `map room` and `map exits` patterns in + Worlds & Characters (F5). Maps are saved per server in a `maps` folder beside `config.json`. +- Ctrl+P ▸ *Show map* opens the focused character's map as a tab, centred on where you stand. Click a room + to walk there, or type `/walk `; the walk sends one step at a time and stops when a + step goes somewhere else, when a line matches the world's `walk fail` pattern (F5), or when a step gets no + answer for 8 seconds. `/walk stop` or Ctrl+P ▸ *Stop walking* ends it, and `/map lock ` keeps walks + out of a room. The status row names the room you are in. +- Screen reader support. `sharpmuterm --plain` runs the client as plain text: game output a line at a time + as it arrives, the client's own messages marked `[client]`, and each line you type is sent. In the desktop + window, output and the client's messages are given to the screen reader as they arrive. ⌃P ▸ `Stop reading + this window aloud` keeps a busy window quiet. The desktop window's support has not yet been tried with a + screen reader. +- Spelling in the composer (F1). Words the dictionary does not know are shown in red, and ⌥W adds the word + at the caret to your own list. The client uses a Hunspell dictionary (`.aff` and `.dic`) from a + `dictionaries` folder beside `config.json`, or the system's (`/usr/share/hunspell` on Linux, + `~/Library/Spelling` on macOS; on Windows only the folder). None is included, and the composer's header + says when there is none. `%r` and the other `%` codes, function names, and words in capitals or with a + capital inside (`OOC`, `McTavish`) are not checked. +- Games that publish a Discord status (GMCP `External.Discord`) can show it on your Discord profile. It is off + for every world; turn it on with F5 ▸ `discord`. Only the world whose window is in front is shown, and it + is cleared when you switch away or disconnect. It talks only to the Discord app on your computer. +- One line can go to several characters. With two characters open, ⌃P offers `Add Ann to the send group` for + each of them, and the rail marks members with ◆. `Send the command line to the group` sends what you typed + to every connected member once and names any that are not connected. Lua scripts can send to another open + character with `world.sendTo('Ann', 'wave')`, which returns false and the reason when it cannot. +- The client answers a server's NEW-ENVIRON request (MNES) with its name, version, terminal type and MTTS + flags, so servers that read MNES identify it. It sends nothing about your computer, and offers nothing to a + server that does not ask. The MTTS flags now include MNES. +- Worlds can log in over GMCP. Set F5 ▸ `gmcp login` to `if offered`, and a character with a saved password is + logged in with GMCP's `Char.Login` on a server that offers it (Achaea and other IRE games, and servers + written for Mudlet). Any other server still gets the connect line, after waiting up to five seconds for the + offer. The password is sent only to the server and never shown or logged. +- Lua scripts can keep values between sessions: `store.set('kills', 3)`, `store.get('kills')` and + `store.keys()`. Each character has its own store, kept in `script-data/` beside `config.json`. A store holds + at most 1,024 keys and 256 KB, and `store.set(key, nil)` removes a key. +- Lua scripts can use the map: `map.current()`, `map.path('Town Square')` and `map.walk('Town Square')`. +- Game sound. A GMCP game that uses MCMP (`Client.Media`, as Mudlet does) can now play sounds and music: + the client asks for it, fetches the files the game names into a cache folder, and plays WAV, MP3, OGG and + FLAC. Text & ANSI (F7) has a SOUND section: on or off, master, sound and music volume, and *sound + captions*, which prints a dim `♪ …` line for each sound. Ctrl+P ▸ *Mute sound* silences everything until + you unmute or restart, and the status row says `♪ muted` meanwhile. Worlds & Characters (F5) has a + `sounds` field to stop a world downloading new files. +- Packages. A package is a `.zip` of trigger sets, Lua scripts and sound files for one or more games. + Ctrl+P ▸ *Install package…* (or `/package install `) shows what it adds first, + including how many of its rules send commands and which scripts it runs, and writes nothing until you + press Enter. Its sets then apply to every character on the games it names, after their own. F10 or + Ctrl+P ▸ *Packages* lists what is installed: Space switches one off, `c` copies its sets into your own + so you can edit them on F2, and Delete (twice) removes it. +- Ctrl+P ▸ *Browse packages* (or `b` on F10, or `/package browse`) lists the packages in the project's + package index, with the current game's first and an update shown beside any you have at an older + version. The index is fetched only when you open it. A package from it is checked against the index's + SHA-256 before its review opens. Another index can be named as `packageIndexUrl` in `config.json`. +- An MXP link with several commands (``) still sends the first on a click, and a + right-click offers all of them: in the window's own menu, or in a small chooser in a terminal (arrows or + 1–9, then Enter). Its tooltip says how many more there are. +- Editing MOO verbs, properties and notes in the composer. On a MOO that speaks MCP 2.1 (LambdaMOO and its + descendants), `@edit` opens what you named in the F1 composer, and Ctrl+S saves it back to the MOO line + for line. An edit that arrives while another window or screen is in front waits for F1 in that + character's window. The client also tells the MOO its name, version and window size, and shows the MOO's + status text on the status row. +- Tab on the command line completes the word you are typing from the game's recent output, with the people + the game names over GMCP (in the room, on a channel) offered first. Tab again goes to the next match and + back round to what you typed. With nothing to complete, Tab still moves to the second command line. +- An `edit grab` prefix per world in Worlds & Characters (F5), empty by default. A line from the game that + starts with it is not shown; its text goes on that character's command line for you to change and send. + AresMUSH uses `FugueEdit >` for its edit commands. The client never sends a grabbed line itself, and a + line you were typing is kept in history (Alt+Up). +- More of MXP: elements a game defines with `` (including room names, exits and numbers, which + feed the automapper), entities from `` and `` (kept with the game data on Ctrl+P ▸ *Show game + data*), `` and `` meters on the status row, `` and ``, which play as MSP sounds + do, and ``, which sends text to a window of its own. A line that is only definitions no longer + prints as a blank line. ### Changed @@ -18,6 +108,19 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] to be fetched again whenever the pane's width had changed, and once per look that arrived while it was still loading. +### Fixed + +- Pueblo worlds work. The client answers a server's Pueblo greeting and switches to Pueblo markup when the + server does, and Pueblo worlds now show their ANSI colours instead of the escape codes. A world set to + `mxp` or `ansi` on F5 does not answer the greeting. +- A character's on-disconnect commands are now sent, before the client closes the connection itself + (Disconnect, Reconnect or quitting). They were saved and never used. +- Lua scripts now run. A trigger set's script files are loaded from the `scripts` folder beside + `config.json` when its character's session opens, and the `script` field of a trigger, alias, timer or + macro calls the Lua function it names, with the match and its groups. Until now no script was ever loaded, + although 0.1.5 listed Lua scripting. Script errors are shown in the character's window and in the client + messages. Files outside the `scripts` folder are refused. + ## [0.1.5] - 2026-10-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0867e2fb..95c62b17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,109 @@ fallbacks) for inline images/maps. and the framework ships none. Reopening it needs an upstream PR making `IImageRenderer` public and `ResolveRenderer` overridable; nothing on our side unblocks it. `InlineImagePolicy` therefore degrades a Sixel-only terminal to half-block explicitly rather than pretending. -- **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log). +- **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log), run per + session by `SessionScripts`. Every rule kind asks for its `script` through one event, + `WorldSession.ScriptRequested`; the bridge tries a `trigger.add`/`alias.add` id first and then a global + (dotted) function name, and a missing name is reported once per load. Decisions not to relitigate: + - **Files come from `/scripts` and nowhere else** (`SessionScripts.Resolve`, ordinal prefix check + after `GetFullPath`). A trigger set is shared configuration and packages will bring them, so a set + naming `/etc/passwd` or `../..` is refused out loud. `scriptsRoot:` is the fifth member of the + `save:`/`logRoot:` family — null by default, so no test and no snapshot runs Lua. + - **Lua runs on the thread that raised the request**, serialised by the host's `_gate`: the read loop + for a trigger, the UI thread for an alias or macro, the scheduler for a timer. `world.send`/`print` go + through the session, which is already safe from those threads. Not moved to the UI thread: a slow + trigger script would then stall the screen instead of the socket. + - **A rebuild clears the engines' runtime rules first** (`ClearRuntime`), or a reload doubles every + `trigger.add`. `SaveConfiguration` rebuilds only when a session's file list changed (`SyncFiles`), + since a rebuild restarts script timers; ⌃P ▸ *Reload scripts* always rebuilds and re-reads. +- **GMCP and MSDP data is held per session in `GameDataStore`, and three things read it**: the status row's + meters (`StatusMeter`, F7 ▸ `meters`), ⌃P ▸ *Show game data* (`GameDataOverlay`) and Lua (`gmcp.get`, + `msdp.get`). GMCP packages are stored by name with objects merged key by key, because IRE and Aardwolf send + partial `Char.Vitals` updates; MSDP variables are stored as `MSDP.NAME`, so one path syntax with `|` + alternatives reads both. Payloads over 64 KiB and packages past 512 are dropped at the door. + - **The client says `Core.Hello` and `Core.Supports.Set` only after the server agrees to GMCP** + (`OnGMCPNegotiated`, `GmcpHandshake`). Before that a GMCP frame would be the unsolicited-option bytes + the login rule above forbids. MSDP has no negotiated callback in the library, so `IAC DO MSDP` is + spotted on the write path and the `REPORT`s follow it. + - **A meter is drawn only once its value has arrived, and from then its fields are reserved** (value and + max five cells each, `StatusMeter.Fit` scales past that). A world with no game data costs the row + nothing; one with it never reflows the row as numbers change. +- **Maps** (`Core/Mapping`: `WorldMap`, `MapRoom`, `PathFinder`, `MapStore`). Mudlet's room model (id, area, + x/y/z, exits by direction and by command, room and exit weights, locks). `MapStore` keeps one file per + **endpoint** under `maps/` (`MsspCache`'s reasoning), memory-only with no directory, never overwrites a + newer client's file and moves an unreadable one aside. `PathFinder` is Dijkstra, not A*: coordinates are + not a lower bound on cost once portals and nudged placements exist, and 10,000 rooms is milliseconds. + - **Rooms come from `IRoomSource`s** (`GmcpRoomSource`: IRE/Aardwolf via TNC's typed `RoomInfo`, MUME's + `id` + object exits + `Room.UpdateExits`; `TextRoomSource`: `MapSettings` patterns) into one + `MapRecorder` per session. Sources raise on the read loop; the recorder applies on the UI thread + (`Post`), in the same queue as `WorldSession.CommandSent`, which is how it knows the way a room was left. + `CommandSent` carries player commands only — never the login line or on-connect commands. + - Maps are written on disconnect, session removal and exit, not per room (`IsDirty`). MXP room tags wait + for `` (Phase 5a). + - **The map window is a window, not a pane control** (`map:`, `WindowKind.Auxiliary`, owned by + the session; `MapRenderer`, Tui). It is drawn from the recorder on `Changed` and whenever its pane's box + changes (`SyncMapSizes` on `PostBufferPaint`), never fed from a line buffer, so its control lives in + `_mapControls` and not `_panes`. Its `LinkClicked` is its own and answers only `mux:map:` — + the output panes' handler never sees a map link, and the map's never sees a send. + - **A speedwalk sends one step and waits for the map to say it arrived** (`Speedwalk`); it goes through + `SendCommandAsync`, so the recorder sees the step. It stops off the path, on the world's + `WalkFailurePattern`, after `StepTimeout` with no room, and on disconnect. + - **Scripts reach the map through `WorldSession.Map` (`SessionMap`)**, and that is the one place the map is + read off the UI thread: every such read takes `MapRecorder.SyncRoot`, which the recorder holds while it + changes the map and never while it raises `Changed` (a handler can end in the alias engine, which takes + the script host's lock). A change a script asks for (`map.walk`, a lock) is posted to the UI thread. +- **Sound** (`Core/Media`: `MediaCommand`, `MediaQueue`, `MediaCache`, `IAudioOutput`). MCMP's + `Client.Media.*` is read with TNC.Gmcp's typed records into `MediaCommand`s (`WorldSession.MediaReceived`), + and one `MediaQueue` per connection applies the specification's rules — priority halts and refuses lower, + a key replaces a different file, music continues unless told not to, an empty stop ends everything — then + drives an `IAudioOutput` the host supplies. MSP becomes the same `MediaRequest`s, flagged `Exclusive` for + its one-sound-at-a-time rule. **`MediaCache` is where server text meets the disk**: http(s) only, relative + names with no `..`, four extensions, per-file and per-world caps enforced while reading, files named by a + hash of their URL; with no root it fetches nothing (the `mssp:` rule). A stop that arrives while a file is + being fetched cancels it. + - **The device is `SharpMUTerm.Audio`'s `DesktopAudio`** (SoundFlow 1.4.2 over miniaudio, plus NVorbis, + because miniaudio reads no Vorbis: an OGG is decoded to a WAV in memory first). It reaches the app as + `ClientHost.Audio`, a *factory*, null in a test, a snapshot and the parity harness; the app calls it on + the first media command, never at start. Opening a device can print on stderr (ALSA, JACK), which in a + terminal paints over the frame, so `TryCreate` points fd 2 at `/dev/null` while it opens. Playback is + verified only against a PulseAudio null sink on Linux; nobody has heard it on Windows or macOS. + - **`Client.Media 1` is advertised only when something can play** (`WorldSession.PlaysMedia`, read at + connect): a host with a device and F7's `game sound` on. A game that checks first then sends nothing. + - **Captions are chrome rows** (`AppendWindowLine`), not session lines, so they reach no transcript, no + trigger and no restore log. With no device they are the only thing a sound becomes. + - **⌃P ▸ Mute is per run and never saved**; F7 is the setting. The `♪ muted` status cell is volatile + only by keystroke, so it needs no reserved width. The cache is `MediaCache.DefaultRoot`, beside the + scrollback spill under the platform cache folder, not beside `config.json`. + - **MSP triggers are read only on a connection that agreed option 90** (`MspTrigger`, + `ITelnetSession.MspChanged`): a whole line `!!SOUND(…)`/`!!MUSIC(…)` becomes a `MediaCommand` and is taken + out before the triggers; anywhere else it is text a player typed. **It is inert until the library ships + `MSPProtocol`** (TelnetNegotiationCore PR #171): no release has it, so `TelnetSession` registers nothing, + the server's `WILL MSP` is refused, and `MspChanged` is never raised. The bump registers the plugin only + when `PlaysMedia` is true and raises the event from its two callbacks. +- **Packages** (`Core/Packages`: `PackageManifest`, `PackageArchive`, `PackageStore`; Tui `PackageScreen`, + `ListOverlay`, `SharpMUTermApp.Packages.cs`). A `.zip` with `package.json` (id, name, SemVer version, + `games` as `host[:port]`, `*.host` or `*`), `sets/*.json`, `scripts/**.lua`, `media/**`; anything else is + listed as left out and never extracted. **The archive is server text meeting the disk**, so `Read` refuses + traversal, absolute and backslashed names by never reading them, and counts sizes while decompressing + (archive, total, per-file, per-text-file, entry count). Decisions not to relitigate: + - **Package sets are never in `config.json`** (`AppConfiguration.Packages` is `[JsonIgnore]`) and are not on + F2–F6. `ResolveTriggerSets(character, world)` appends the enabled ones for that world *after* the + character's own, so an update replaces them wholesale and never touches the player's edits; `c` on F10 + copies them into the player's sets to edit. The only thing saved is `DisabledPackages`. + - **The review is the consent step**: nothing is written until ⏎, and it leads with what can act as the + player (rules that send, scripts that run). Downloads are https only, capped while streaming. + - **Scripts are `package:/`** and `SessionScripts` resolves them only under that package's + `scripts/`, through `PackageStore.ResolveScript`. They still need the scripts root, like any script. + - One folder per package (`packages//`, beside `config.json`), swapped in through a `.id.new` staging + folder; a snapshot's store has no folder and writes nothing. F10 is claimed (`opens Packages`); GNOME + Terminal may take it for its menu, so ⌃P ▸ *Packages* is the way in that always works. + - **The index** (`PackageIndex`; ⌃P ▸ *Browse packages*, `b` on F10) is `{"packages":[…]}`, each entry a + manifest plus an https `url` and a `sha256`, at `AppConfiguration.PackageIndexUrl`. **It is fetched only + when the player opens it, never at start.** An entry that breaks a rule is dropped and counted, not + fatal to the list. The archive is checked against the entry's SHA-256 *and* its id and version before + the review opens, so the review is never shown for bytes the index did not name. The focused window's + world (its session's, else its owner's, never `_active`) sorts its own packages first; a `*` package does + not count as the world's own. The default index repository does not exist yet. - **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn windows** + web view, with unread badges), each a `MarkupControl` in a `ScrollablePanelControl` viewport (PgUp/PgDn, Shift+↑/↓, ⌃Home/⌃End, wheel; unread badges count output arriving below a @@ -480,8 +582,19 @@ fallbacks) for inline images/maps. it *before* the screen lookup — an arm reached only on a miss would make every future unclaimed F-key silently open the composer. Claiming it takes it off `MacroKeys.Bindable` automatically, which is why a macro test that used F1 as "a free function key" had to move to F12. - - **The footer names ⌃S, ⌥L and Esc and nothing else.** No `⌃F find`: the control has a find *API* and - no chord bound to it, and this screen is held to the same honesty rule as the settings screens. + - **The footer names ⌃S, ⌥L, ⌥W and Esc and nothing else.** No `⌃F find`: the control has a find *API* and + no chord bound to it, and this screen is held to the same honesty rule as the settings screens. ⌥W is + named only while a dictionary is open. + - **Spelling is a colour, not an underline** (`SpellChecker`, Core; `SpellingHighlighter`, Tui). The marks + go through `MultilineEditControl.SyntaxHighlighter`, whose tokens carry a foreground and nothing else; + an underline needs a styling hook upstream. Hunspell via WeCantSpell.Hunspell, **no dictionary + bundled**: `dictionaries/` beside `config.json`, then the system folders, chosen by the POSIX locale + variables — not `CultureInfo`, because the build is `InvariantGlobalization` and every culture is the + invariant one. The dictionary is opened the first time F1 opens, never at startup, and the app takes a + `Func` that is null by default, so a test and a snapshot read none and their header says + `no dictionary`. A MOO edit is not checked (its buffer is code as often as prose). `%`-codes, a word + before `(`, and any word with a digit or an inner capital are skipped. ⌥W appends to + `dictionaries/user-words.txt`. - **An MXP `` is drawn in the output pane as rows of markup under the line that named it** (`MxpParser.HandleImage` + `MxpImageSource`, Core; `InlineImageHost`/`InlineImageRows`/`InlineImageLayout`, Tui; the `mxp-image` view). The parser emits a `[image: name]` span whose `SpanInteraction.Image` carries @@ -868,6 +981,8 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg tabs — the only geometry where an idle chip can be seen wearing a colour the pane behind it is not, which is the whole of the per-tab chip), `deletions`, + `moo-edit` (the composer holding a MOO verb from MCP simpleedit — `EDIT`, the name and its type in the + header, `⌃S save` and no escaping in the footer), `compose`/`compose-literal` (the F1 composer in each of its two escaping modes — the pair exists because ⌥L changes what is *sent* and only the header says which way it is set; the demo has no session, so the target is handed in and pinned against the live writer by `ComposeWindowTests`), @@ -1591,9 +1706,10 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] pin said 2.8.1. The notes below say which release changed what, never what we are on. (Repo owner is its author — extend it by PR rather than working around it.) Fluent builder API; negotiates MCCP/MSDP/MXP itself; ships the keepalive interpreter - (`WithKeepAlive(TimeSpan?, …)`, default 30s, clamped to 1s–24h). `TelnetSession` sets the - init-only `CallbackOnByteAsync` reflectively to see raw bytes including unterminated prompts — a - first-class `OnByte` builder hook remains a good upstream PR. It handles the option handshake + (`WithKeepAlive(TimeSpan?, …)`, default 30s, clamped to 1s–24h). A prompt's text is the + interpreter's own `LastPromptBytes`, read inside the prompt callback; the reflective + `CallbackOnByteAsync` copy of every byte that used to stand in for it is gone (see the MCP entry). + It handles the option handshake (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, CHARSET, MSSP, GMCP) — **Pueblo and all ANSI/MXP/Pueblo payload _parsing_ stay our layer.** - **2.8.1 changes that affected us:** `TelnetInterpreter.SendNAWS(short, short)` was removed — @@ -1617,10 +1733,7 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] `IProtocolContext` is unchanged in 2.12.0: the prompt-boundary members (`TakePartialLineAsPrompt`, `HasPartialLine`, `HasSeenMarkedPrompt`) are public on `TelnetInterpreter`, reachable through the interface's existing `Interpreter` property, so there - is no public break to migrate for. The - `CallbackOnByteAsync` property this file reaches by reflection is unchanged across the whole jump: - still `public Func? CallbackOnByteAsync { get; init; }` on - `TelnetInterpreter`. + is no public break to migrate for. - **`cd06e8b` (the prompt render) and `04e0e26` (this 2.12.0 bump) must not be separated.** Reverting the render alone while 2.12.0 stays pinned sends every packet-patch-inferred prompt fragment to `CurrentPrompt`, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same @@ -1629,20 +1742,19 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] - **The 2.x → 4.x jump compiled with no source change here.** 4.0.0 replaced the interpreter with a generated state machine (4 KiB batches, returning at MCCP boundaries); 3.0.0 dropped OneOf; 4.1.1 made a second plugin for one protocol throw (EOR, SGA and PacketPatch are three plugins, each - registered once — keep it that way). The two reflective reaches above (`CallbackOnByteAsync`, - `CurrentEncoding`'s internal setter) and `NAWSProtocol`'s subclassability are unchanged. The only + registered once — keep it that way). The reflective reach into `CurrentEncoding`'s internal + setter and `NAWSProtocol`'s subclassability are unchanged. The only build change was `Microsoft.Extensions.Logging.Abstractions`, which 4.x needs at a newer patch. What the jump *makes available* and is not yet used: a GMCP `Core` client session (`UseGmcpClientSession`, `TelnetNegotiationCore.Gmcp`), typed GMCP packages, MSDP over GMCP, - `PuebloProtocol`, and MCP 2.1 (`MudClientProtocol`). + `PuebloProtocol`, and MCP 2.1 (`MudClientProtocol`, now used — see below). - **Never pin a version that is not on nuget.org, and never work around it with a local feed.** A pin that only resolves from a local `nuget.config` builds here and fails `NU1101` everywhere else; if a locally-built nupkg of that version is already in `~/.nuget/packages/`, the real package publishing later fails `NU1403` (content mismatch) until that cache directory is deleted. Delete the stale cache; do not commit a `nuget.config` pointing at `/tmp`. - **A prompt ends with `IAC EOR` or `IAC GA`, and until 2.11.0 this client could only see the first - of them.** `TelnetSession.OnPromptAsync` is the only thing that flushes `_pending` — the - unterminated line `CallbackOnByteAsync` accumulates — so a server whose prompt boundary the library + of them.** `TelnetSession.OnPromptAsync` is the only thing that emits an unterminated line, so a server whose prompt boundary the library does not report leaves that prompt in the buffer for ever. Both halves were wrong in the library and were fixed upstream rather than worked around here (PR #90): - **GA is the boundary a default NVT uses**, and most MU\* servers are one. RFC 854 makes it @@ -1658,8 +1770,7 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] - Nothing on our side changes for any of it. `AddDefaultMUDProtocols(onPrompt: OnPromptAsync)` already wires one callback to both plugins, so the signal arrives on the version bump. - **What is still ours**: a server that sends no boundary marker at all — no GA, no EOR, no newline - — still leaves a prompt in `_pending`, because nothing here flushes on idle. That is a real gap - and a deliberate non-decision, not something the library can fix. + — is now `PacketPatchProtocol`'s, which infers a boundary from silence. - **MXP's line-security model is the parser's real job, and the allow-list is chosen deliberately over a deny-list** (`MxpLineMode`, `MxpTagCategory`, `MxpParser` — the eight `ESC[#z` line tags: OPEN, SECURE, LOCKED, TEMP SECURE, and LOCK OPEN/SECURE/LOCKED plus RESET). The spec draws its own line in @@ -1717,8 +1828,8 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] is parser state too: MXP has to be negotiated again on the new socket to take the session off ANSI. Left alone, a server's `ESC[6z` made the *next* connection start in secure default, and the black-on-black above survived the reconnect that is the obvious way a user tries to get rid of it. - - **`AnsiParser` and `MxpParser` hold near-duplicate escape state machines, deliberately - unconsolidated — so a fix to one has to be applied to the other, with its own test in each suite.** + - **`AnsiParser`, `MxpParser` and `PuebloParser` hold near-duplicate escape state machines, deliberately + unconsolidated — so a fix to one has to be applied to the others, with its own test in each suite.** (Same states, same CSI ranges, same OSC/DCS/ST handling; `MxpParser`'s differs only by the `z` line-tag arm and by disarming TEMP SECURE on the way out.) This was weighed and kept: extracting a shared scanner is a structural refactor, and it was not going into a fix wave that already carried @@ -1759,12 +1870,21 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] written while the mode never reverted in production, where "lands on an OPEN line" was not true of a session a server had ever secured. Closing the buffering hole itself needs its own change at the telnet layer and is out of this scope. -- **`PuebloParser` decodes no ANSI, and nothing else decodes it for it.** Its header used to say escapes - were "handled upstream" — the same false premise `MxpParser` carried, in the same words, and there is - no upstream either way: a session gets one parser. So a Pueblo world's colour arrives on screen as a - literal `[0;33m`. The behaviour clause was accurate; only the justification was invented. The fix - is the one `MxpParser` already had — its own escape state machine over `SgrCodes` — and until someone - does it this is a known gap rather than a design. +- **`PuebloParser` decodes ANSI itself, through `SgrCodes`**, for `MxpParser`'s reason: a session gets one + parser and nothing chains another in front of it. Until it did, a Pueblo world's colour arrived as a + literal `[0;33m`. Its escape machine is the third copy (see the paragraph on the duplicates above). +- **Pueblo is negotiated the way MXP is** (`PuebloProtocol`, TelnetNegotiationCore; `TelnetSession`'s + `OnPuebloOffered`/`OnPuebloEnabled`; `WorldSession.UpgradeParser`). Pueblo has no telnet option: the server + prints `This world is Pueblo 1.10 Enhanced.`, the client answers with the text line `PUEBLOCLIENT 2.50`, + and the server's `` switches the connection. Three rules: + - **The answer goes only to a server that said hello, and only from `auto` or `pueblo`** + (`TelnetSessionOptions.AnnouncePueblo`). It is a line at a login prompt, where a server without Pueblo + reads it as a name — the unsolicited-negotiation rule, in text. + - **The first markup a connection negotiates keeps it.** The parser is swapped only off the plain + `AnsiParser`, and the hello is not answered once MXP is on, so a server offering both is not flipped + between them by the order the handshakes finish in. + - **The library consumes both lines**, and a prompt is read from `LastPromptBytes`, so neither can be + flushed as text by a boundary that follows it. - **``/`` are answered, and the `SUPPORTS` list is held to the same rule as the MTTS bit vector: an honest claim, not an aspirational one.** The spec requires both replies to go out as a secure-tagged line (`ESC[1z` prefix, `MxpParser.SecureLinePrefix`) specifically so a server can tell @@ -1777,6 +1897,118 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] drops it on the floor, unhandled. Claiming `+high` would tell a server to send markup this client silently discards, exactly the failure mode the MTTS bit vector is already held to; add a tag to this list only once the handler for it exists, never once the tag is merely accepted. +- **MOO editing is MCP 2.1, and the library does the protocol** (`MudClientProtocol`, registered for every + world in `TelnetSession`; `MooEditing`, Core; `SharpMUTermApp.MooEdit.cs`, Tui; the `moo-edit` view). The + server opens MCP (`#$#mcp version`), so registering it sends nothing to a server that never offers it, and + the library checks the authentication key, so a `#$#` line a player types is dropped and never opens an + edit. Three packages: `dns-org-mud-moo-simpleedit` (edit and save), `dns-com-vmoo-client` (name, version + and screen size, sent once the package list is agreed and again on each NAWS change) and + `dns-com-awns-status` (the `»` cell on the status row, reserved and elided like the room cell). + Decisions not to relitigate: + - **An edit opens in the composer only when its character's window is in front and nothing else is + open**; otherwise it is held and F1 in that window opens it. A modal on wire input must not land over + another window, or over a surface somebody is typing into. + - **Edits are held apart from post drafts** (`_mooEdits`, one per character, the newest winning), so an + edit arriving never replaces a half-written post. Esc keeps the edit; a save lets it go only once the + MOO has it, and a failed save keeps it. + - **An edit is saved as its lines** (`MooEditing.LinesOf`): no `%r` join, no escaping, nothing trimmed but + the caret's last newline. ⌥L does nothing in edit mode and the footer does not offer it. + - **A prompt is `LastPromptBytes`, not a copy of every byte.** The library takes MCP lines out of the + stream after their bytes have passed any per-byte hook, and nothing marks where a consumed line + ended, so a GA straight after an MCP message printed the message as the prompt. Reading the + interpreter's own partial line removed the reflective `CallbackOnByteAsync` hook along with the bug. + - **Not done: the legacy LambdaMOO local edit** (`#$# edit name: … upload: …`). The library drops every + line-initial `#$#` that is not a well-formed MCP message, in a session or outside one, so that header + never reaches this client. Supporting it needs the library to hand such a line back outside a session. + - **vmoo-client info goes out on `OnMcpNegotiationComplete`**, which a 1.0 peer (no `mcp-negotiate-end`) + never raises; such a MOO gets no client info. Editing does not depend on it. +- **Screen readers get an announcer, never the compositor** (`IAnnouncer`, Core; `ClientHost.Announcer`; + `SharpMUTermApp.Announce.cs`). The app announces each line at `OnLine`/`OnSpawnLine` as plain `StyledLine.Text`, + every `Notice`, and the window `Activate` brings forward. A window can be kept quiet from ⌃P (`a11y:toggle-window`, + in memory, offered only when the host has an announcer). The terminal host has none, because a cursor-addressed + screen reads as noise. **`--plain`** (`PlainHost`, in `Hosting/`) is the terminal's answer: the same app on a + `HeadlessConsoleDriver` with the loop running (`RunsInline = false`). `PlainAnnouncer` writes to stdout, and + `PumpInput` reads the real stdin a line at a time into `SimulateKeyPress` plus ⏎, then sends ⌃Q, Y at EOF. The + framework is handed `TextReader.Null`, and the pump is started from the loop (`RunLive`'s `ready`), because a + key raised before `Run()` has no listener and is lost. The window (`WindowAnnouncer`) uses two hidden live + regions: output is polite and notices are assertive, posted to Avalonia's thread. This was built against the + API and has not been tried with NVDA or Orca. Not done: no speech engine of our own, and no per-line + automation peer on `TerminalView`. +- **Discord presence is a host member and a per-world opt-in** (`ClientHost.Presence`, a lazy + `Func?`; `GamePresence`/`DiscordIpc`, Core; F5 ▸ `discord`, off by default). The world asks for + `External.Discord 1`. `WorldSession.GamePresence` folds `Info` (application id: digits only) and `Status` + (fields trimmed to Discord's 128). The app shows the `SendTarget()` session's presence while it is connected + and opted in, and clears it on a window change, a state change or a world that has not opted in. The host is + opened only when there is something to show, so a client whose worlds never opt in never touches the socket. + `DiscordIpc.Show` never blocks: a worker sends only the newest value, reads and discards each reply (bounded + in size and time), and on a failed connect does not retry until the next update. The application id is the + game's; this client registers none. The snapshot and every test host have `Presence = null`. +- **The send group is session keys in memory** (`SendGroup`, Core; `SharpMUTermApp.SendGroup.cs`; ⌃P `group:` + ids). It is never saved, because a saved group would be a standing order to send one line to several worlds. + It is offered once two characters are open. Members are marked on the rail by swapping the dot for ◆/◇ in + the same cell, so joining moves no column (the NAWS rule). `group:send` sends the armed bar's text through + each member's `SendUserInputAsync`, so aliases and echo apply per character, once each. A disconnected member + gets nothing and is named in the notice. A closed session leaves the group. `world.sendTo(name, cmd)` resolves + through `SendGroup.Find`: an exact `World.Character` key, else a unique character name. It refuses an + ambiguous or disconnected name and sends raw, like `world.send`. Not done: no ⌃P chord, and no rail gesture + to toggle membership. +- **NEW-ENVIRON is answered as MNES** (`TelnetSessionOptions.MnesIdentity`; the library's `NewEnvironProtocol`). + The variables are `CLIENT_NAME`, `CLIENT_VERSION`, `TERMINAL_TYPE` and `MTTS`, read from the same TTYPE list, + so the two channels cannot disagree (`MTTS 2845`, which now includes MNES 512). It answers a server's `DO` and + initiates nothing. There is no `IPADDRESS` and no `USER`. There is no `CHARSET` either: the answer is fixed at + connect, CHARSET can settle later, and the library has no `INFO` send to correct it. +- **GMCP `Char.Login` is opt-in per world** (`WorldDefinition.GmcpLogin`, F5 ▸ `gmcp login`; `GmcpLogin`, Core). + On, the world asks for `Char.Login 1`, and a character whose `LoginPlan` is `WithPassword` holds its connect + line back. `Char.Login.Default` offering `password-credentials` gets `Char.Login.Credentials`; any other + answer gets the connect line; silence gets it after `GmcpLoginWait` (5 s). The wait ends once, under a lock, + so a late offer cannot log in twice. The on-connect commands go after whichever login was sent. The password + goes only to `SendGmcpAsync`; `Char.Login.Result` is printed, bounded to one line. Not done: a server that + never negotiates GMCP still costs the five seconds, because `TelnetSession` raises nothing on a refusal. +- **Lua's `store.*` is one JSON file per character** (`ScriptStore`, Scripting; `script-data/.json` + beside `config.json`, `0600`). The `scriptStoreRoot:` constructor parameter is null by default, the + `save:`/`logRoot:` family, but the host's store never is: with no folder it is memory-only by construction, the + `MsspCache` rule, so a test that runs scripts writes nothing. The store belongs to `SessionScripts`, not to a + `ScriptHost`, so it outlives a reload. Bounds are enforced at `set` (1,024 keys, 128-character keys, 256 KB), + and a refused `set` changes nothing and raises a Lua error. Every change is written at once through a + temporary file and a rename. A file that does not parse is moved to `.bad` and not overwritten. A session key + that is not a safe file name gets a hash suffix, so `A/B` and `A_B` never share a file. +- **⇥ completes the last word on the command line** (`CompletionIndex`, Core, one per `WorldSession`; + `InputBarControl.Complete`; `SharpMUTermApp.Completion.cs`). Names from GMCP (`Room.Players`, + `Room.AddPlayer`, `Comm.Channel.Players`, `Comm.Channel.Text`'s talker) first, then words of output the + session *showed* (a gagged line's words were never read), newest first, capped at 4,096 words and 512 + names. Repeated ⇥ cycles and ends on what was typed; any edit ends the cycle, because the cycle is keyed + on the exact text it left. **Only at the end of the buffer**: the bar exposes no caret offset to replace a + mid-line word through, and that is where a MUD line is typed. **Ahead of the sibling-bar cycle, and only + when there is a candidate**, so ⇥ with nothing to complete still moves to the second bar. A focus-in Tab + never reaches the bar: `TerminalFocusWatcher` claims it as a global shortcut first. Not on F4, because ⇥ + is not taken outright. +- **A MUSH's edit grab goes on the command line and is never sent** (`WorldDefinition.GrabPrefix`, F5 ▸ + `edit grab`, null by default; `WorldSession.TryGrab`; `SharpMUTermApp.Grab.cs`). Another player can page a + line that starts with the prefix, so the player always sees the text and presses ⏎. Matched exactly and at + the start, before the triggers, so a rule can neither see nor send it. It lands on the bar of the window it + came from — at once when that window is in front, otherwise when it is next brought forward + (`_pendingGrabs`), never on another character's bar. A line being typed goes into history first. It is + always the command line and never the composer: a grab is one line, and the bar scrolls past its cap. +- **What a server defines over MXP is held per connection and bounded** (`MxpParser.Definitions.cs`). + ``, ``, ``, ``/``, ``/``, `` and `` are all + secure tags, so a player can neither define nor send one. Decisions not to relitigate: + - **An element is expanded through the same trust as the tag that opened it.** An `OPEN` element a player + uses on an open line runs its definition's tags as an open line would, so a definition carrying `` + gives the player formatting and never a command. The built-in tags and entities cannot be redefined. + - **Every expansion is capped** (256 elements, 512 entities, 1 KB a definition, depth 4, 64 per line, 8 + frames). A definition that names itself stops at the depth cap rather than at the stack's end. + - **A tag ends at a `>` outside a quoted value**, because a definition carries markup in one. A quote opens + only after `=` or a space, so the apostrophe in `` holds nothing open, and **a tag still being read + is abandoned as text at the line boundary** (`EndLine`, i.e. `Flush`), not only at a `'\n'` the product + never feeds. + - **A line that was only markup is swallowed** (`LineSwallowed`), and `WorldSession` skips the blank line it + would otherwise print. A genuinely blank line still prints. + - **`` lines skip the trigger engine**: they reach the window by `SpawnLine`, already shown, as a + capture rule's would. `` raises `FrameDeclared` and nothing listens yet; a window opens when text + is first sent to it. + - **Entities are game data** (`GameDataStore`, under `MXP.`), so the game data view and a gauge's meter read them where + they read GMCP. `PRIVATE` entities are kept from both. + - **A restored line keeps only a menu's first command**: `StyledLineCodec` does not carry `SendChoice`s. - **MSSP is read by the library, not by us — since 2.6.5, and that is the standing example of the rule above.** 2.6.0's reader destroyed the protocol's own array notation inside the library: `PORT "80" "23" "4201"` arrived as the integer `80234201`, `REFERRAL` (array-only) arrived null, @@ -1809,7 +2041,7 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] bite you, and all four already have: - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.UTF8`** (`TelnetStandardInterpreter.cs:46`), and that default is not something to rely on unseeded: it is handed to - `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP and everything + `CallbackOnSubmitAsync` for every line and used for GMCP, MSDP and everything we send, before this session's own encoding decision (an override, or the head of `CharsetOrder`) has had any say. `TelnetSession` seeds that property (reflectively, `internal set`, the same way `CharsetProtocol` itself writes it) with the head of the stated order — otherwise a world pinned to diff --git a/Directory.Packages.props b/Directory.Packages.props index 007afc90..53fdfa69 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,22 @@ --> + + + + + + + diff --git a/README.md b/README.md index ba211ef5..ba6a7197 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ fallbacks) for inline images and maps. - **.NET 10** / C# - **[SharpConsoleUI](https://github.com/nickprotop/ConsoleEx)** — compositor-based TUI framework: split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a native Kitty graphics protocol (+ Sixel/half-block) -- **[TelnetNegotiationCore](https://www.nuget.org/packages/TelnetNegotiationCore/)** — telnet option negotiation (NAWS, MTTS, CHARSET, EOR/GA, MSSP, GMCP) +- **[TelnetNegotiationCore](https://www.nuget.org/packages/TelnetNegotiationCore/)** — telnet option negotiation (NAWS, MTTS, NEW-ENVIRON/MNES, CHARSET, EOR/GA, MSSP, GMCP) - **[MoonSharp](https://www.moonsharp.org/)** — embedded, sandboxed Lua scripting - Custom app-layer parsers for **ANSI** (256 + 24-bit), **MXP**, and **Pueblo** diff --git a/SharpMUTerm.slnx b/SharpMUTerm.slnx index bf71b928..c303b035 100644 --- a/SharpMUTerm.slnx +++ b/SharpMUTerm.slnx @@ -1,5 +1,6 @@ + diff --git a/src/SharpMUTerm.Audio/DesktopAudio.cs b/src/SharpMUTerm.Audio/DesktopAudio.cs new file mode 100644 index 00000000..44a5a5d2 --- /dev/null +++ b/src/SharpMUTerm.Audio/DesktopAudio.cs @@ -0,0 +1,474 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using SharpMUTerm.Core.Media; +using SoundFlow.Abstracts.Devices; +using SoundFlow.Backends.MiniAudio; +using SoundFlow.Backends.MiniAudio.Enums; +using SoundFlow.Components; +using SoundFlow.Enums; +using SoundFlow.Providers; +using SoundFlow.Structs; + +namespace SharpMUTerm.Audio; + +/// +/// The sound card, through SoundFlow (miniaudio). Every file is decoded to one device format — 48 kHz stereo +/// float — because a SoundFlow player does not resample: a 22 kHz file played on a 48 kHz device plays at +/// twice the speed. miniaudio's own decoder converts WAV, MP3 and FLAC as it reads; OGG Vorbis is decoded by +/// NVorbis into a WAV the same decoder then converts (). +/// +/// Opening a device can write to the terminal. The native libraries print diagnostics on stderr — ALSA +/// on a machine with no card, JACK when it is tried and not running — and in a terminal client stderr is the +/// screen. So the backends are named per platform (JACK never), and on Unix stderr is pointed at +/// /dev/null for the moment the device opens. +/// +/// +public sealed class DesktopAudio : IAudioOutput +{ + internal static readonly AudioFormat DeviceFormat = new() + { + SampleRate = 48000, + Channels = 2, + Format = SampleFormat.F32, + Layout = AudioFormat.GetLayoutFromChannels(2), + }; + + private readonly MiniAudioEngine _engine; + private readonly AudioPlaybackDevice _device; + private readonly ILogger? _logger; + private readonly List _voices = new(); + private readonly object _gate = new(); + + private DesktopAudio(MiniAudioEngine engine, AudioPlaybackDevice device, ILogger? logger) + { + _engine = engine; + _device = device; + _logger = logger; + } + + /// Opens the default playback device, or returns null — no device, no backend, no native library. + public static IAudioOutput? TryCreate(ILogger? logger = null) + { + MiniAudioEngine? engine = null; + try + { + using (QuietStderr.Begin()) + { + engine = new MiniAudioEngine(Backends()); + var device = engine.InitializePlaybackDevice(null, DeviceFormat, null); + device.Start(); + logger?.LogInformation("Sound: playing through {Backend}", engine.ActiveBackend); + return new DesktopAudio(engine, device, logger); + } + } + catch (Exception ex) when (ex is InvalidOperationException or DllNotFoundException or TypeInitializationException + or BadImageFormatException or EntryPointNotFoundException or SoundFlow.Exceptions.BackendException) + { + engine?.Dispose(); + logger?.LogInformation("Sound: no audio device ({Reason})", ex.Message); + return null; + } + } + + private static MiniAudioBackend[] Backends() => + OperatingSystem.IsWindows() ? [MiniAudioBackend.Wasapi, MiniAudioBackend.DirectSound, MiniAudioBackend.WinMm] + : OperatingSystem.IsMacOS() ? [MiniAudioBackend.CoreAudio] + : [MiniAudioBackend.PulseAudio, MiniAudioBackend.Alsa]; + + public IAudioVoice? Play(string path, AudioStart start) + { + Stream? stream = null; + try + { + stream = Path.GetExtension(path).Equals(".ogg", StringComparison.OrdinalIgnoreCase) + ? OggToWav(path) + : File.OpenRead(path); + var provider = new StreamDataProvider(_engine, DeviceFormat, stream); + var player = new SoundPlayer(_engine, DeviceFormat, provider); + var voice = new Voice(this, player, provider, stream, start); + lock (_gate) + { + _voices.Add(voice); + } + + _device.MasterMixer.AddComponent(player); + voice.Begin(); + return voice; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException + or InvalidDataException or ArgumentException or NotSupportedException + or SoundFlow.Exceptions.BackendException) + { + stream?.Dispose(); + _logger?.LogInformation("Sound: could not play {File} ({Reason})", Path.GetFileName(path), ex.Message); + return null; + } + } + + public void Dispose() + { + Voice[] voices; + lock (_gate) + { + voices = _voices.ToArray(); + } + + foreach (var voice in voices) + { + voice.Dispose(); + } + + _device.Stop(); + _device.Dispose(); + _engine.Dispose(); + } + + private void Remove(Voice voice) + { + lock (_gate) + { + _voices.Remove(voice); + } + + _device.MasterMixer.RemoveComponent(voice.Player); + } + + /// + /// An OGG Vorbis file as a 16-bit PCM WAV in memory, at the file's own rate and channels, for miniaudio to + /// convert. Bounded by the cache's per-file cap on the compressed file, and by . + /// + internal static MemoryStream OggToWav(string path, int maxSeconds = 20 * 60) + { + using var reader = new NVorbis.VorbisReader(path); + var channels = reader.Channels; + var rate = reader.SampleRate; + if (channels is < 1 or > 8 || rate is < 4000 or > 192000) + { + throw new InvalidDataException("not a playable Vorbis stream"); + } + + var limit = (long)rate * channels * maxSeconds; + var pcm = new MemoryStream(); + var writer = new BinaryWriter(pcm); + WriteWavHeader(writer, channels, rate, 0); + var buffer = new float[rate * channels / 4]; + long written = 0; + int read; + while ((read = reader.ReadSamples(buffer, 0, buffer.Length)) > 0 && written < limit) + { + for (var i = 0; i < read; i++) + { + writer.Write((short)Math.Clamp((int)(buffer[i] * 32767f), short.MinValue, short.MaxValue)); + } + + written += read; + } + + writer.Flush(); + pcm.Position = 0; + WriteWavHeader(writer, channels, rate, (int)(written * 2)); + writer.Flush(); + pcm.Position = 0; + return pcm; + } + + private static void WriteWavHeader(BinaryWriter writer, int channels, int rate, int dataBytes) + { + writer.Write("RIFF"u8); + writer.Write(36 + dataBytes); + writer.Write("WAVE"u8); + writer.Write("fmt "u8); + writer.Write(16); + writer.Write((short)1); + writer.Write((short)channels); + writer.Write(rate); + writer.Write(rate * channels * 2); + writer.Write((short)(channels * 2)); + writer.Write((short)16); + writer.Write("data"u8); + writer.Write(dataBytes); + } + + /// + /// One file playing. Its volume is the queue's level times an envelope — the fade in at the start, the fade + /// out before the end of the last loop, and any fade the queue asked for — recomputed on a 40 ms tick, which + /// also ends the file at its finish time and counts its loops. + /// + /// Loops are the player's own (IsLooping), counted on the tick by the play position going backwards, + /// and switched off for the last one so it ends by itself. Restarting from PlaybackEnded instead does + /// not work: that event is raised on the audio thread, and a seek and play from inside it never plays. + /// + /// + private sealed class Voice : IAudioVoice + { + private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(40); + + private readonly DesktopAudio _owner; + private readonly StreamDataProvider _provider; + private readonly Stream _stream; + private readonly AudioStart _start; + private readonly Timer _timer; + private readonly object _gate = new(); + private float _level; + private (float From, float To, DateTime At, TimeSpan Over)? _fade; + private DateTime _loopStarted; + private int _loopsLeft; + private float _lastTime; + private bool _paused; + private bool _ended; + + public Voice(DesktopAudio owner, SoundPlayer player, StreamDataProvider provider, Stream stream, AudioStart start) + { + _owner = owner; + Player = player; + _provider = provider; + _stream = stream; + _start = start; + _level = start.Volume; + _loopsLeft = start.Loops; + _timer = new Timer(_ => OnTick(), null, Timeout.Infinite, Timeout.Infinite); + player.PlaybackEnded += (_, _) => OnLoopEnded(); + } + + public SoundPlayer Player { get; } + + public event EventHandler? Ended; + + public void Begin() + { + lock (_gate) + { + Player.IsLooping = _loopsLeft != 1; + if (_start.Start > TimeSpan.Zero) + { + Player.Seek(_start.Start, SeekOrigin.Begin); + } + + _loopStarted = DateTime.UtcNow; + Apply(); + Player.Play(); + _timer.Change(Tick, Tick); + } + } + + public void SetVolume(float volume) + { + lock (_gate) + { + _level = Math.Clamp(volume, 0f, 1f); + _fade = null; + Apply(); + } + } + + public void FadeTo(float volume, TimeSpan over) + { + lock (_gate) + { + _fade = (_level, Math.Clamp(volume, 0f, 1f), DateTime.UtcNow, over); + } + } + + public void Pause() + { + lock (_gate) + { + _paused = true; + Player.Pause(); + } + } + + public void Resume() + { + lock (_gate) + { + _paused = false; + Player.Play(); + } + } + + public void Dispose() => End(raise: true); + + /// A loop came round: count it, and let the last one end by itself. + private void OnWrapped() + { + _loopStarted = DateTime.UtcNow; + if (_loopsLeft > 1 && --_loopsLeft == 1) + { + Player.IsLooping = false; + } + } + + private void OnLoopEnded() + { + lock (_gate) + { + if (_ended) + { + return; + } + } + + End(raise: true); + } + + private void OnTick() + { + var finished = false; + lock (_gate) + { + if (_ended || _paused) + { + return; + } + + var time = Player.Time; + if (time + 0.05f < _lastTime) + { + OnWrapped(); + } + + _lastTime = time; + if (_start.Finish is { } finish && time >= finish.TotalSeconds) + { + // A finish point ends the loop there: the next loop starts again from the start point. + if (_loopsLeft == -1 || _loopsLeft > 1) + { + Player.Seek(_start.Start > TimeSpan.Zero ? _start.Start : TimeSpan.Zero, SeekOrigin.Begin); + _lastTime = 0; + OnWrapped(); + } + else + { + finished = true; + } + } + else + { + Apply(); + } + } + + if (finished) + { + End(raise: true); + } + } + + /// The level times the envelope, written to the player. Called under the lock. + private void Apply() + { + var now = DateTime.UtcNow; + if (_fade is { } fade) + { + var t = fade.Over <= TimeSpan.Zero ? 1f : (float)Math.Clamp((now - fade.At) / fade.Over, 0, 1); + _level = fade.From + (fade.To - fade.From) * t; + if (t >= 1f) + { + _fade = null; + } + } + + var envelope = 1f; + if (_start.FadeIn > TimeSpan.Zero) + { + envelope = Math.Min(envelope, (float)Math.Clamp((now - _loopStarted) / _start.FadeIn, 0, 1)); + } + + var lastLoop = _loopsLeft is 1; + if (lastLoop && _start.FadeOut > TimeSpan.Zero) + { + var end = _start.Finish?.TotalSeconds ?? Player.Duration; + var left = end - Player.Time; + envelope = Math.Min(envelope, (float)Math.Clamp(left / _start.FadeOut.TotalSeconds, 0, 1)); + } + + Player.Volume = _level * envelope; + } + + private void End(bool raise) + { + lock (_gate) + { + if (_ended) + { + return; + } + + _ended = true; + } + + _timer.Dispose(); + try + { + Player.Stop(); + } + catch (InvalidOperationException) + { + } + + _owner.Remove(this); + Player.Dispose(); + _provider.Dispose(); + _stream.Dispose(); + if (raise) + { + Ended?.Invoke(this, EventArgs.Empty); + } + } + } + + /// Points stderr at /dev/null while a native library is being opened, on Unix; does nothing elsewhere. + private static class QuietStderr + { + public static IDisposable Begin() + { + if (OperatingSystem.IsWindows()) + { + return new Restore(-1); + } + + try + { + var saved = Dup(2); + var devNull = Open("/dev/null", 1); + if (saved < 0 || devNull < 0) + { + return new Restore(-1); + } + + Dup2(devNull, 2); + Close(devNull); + return new Restore(saved); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return new Restore(-1); + } + } + + private sealed class Restore(int saved) : IDisposable + { + public void Dispose() + { + if (saved >= 0) + { + Dup2(saved, 2); + Close(saved); + } + } + } + + [DllImport("libc", EntryPoint = "dup", SetLastError = true)] + private static extern int Dup(int fd); + + [DllImport("libc", EntryPoint = "dup2", SetLastError = true)] + private static extern int Dup2(int fd, int fd2); + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int Open([MarshalAs(UnmanagedType.LPStr)] string path, int flags); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int Close(int fd); + } +} diff --git a/src/SharpMUTerm.Audio/SharpMUTerm.Audio.csproj b/src/SharpMUTerm.Audio/SharpMUTerm.Audio.csproj new file mode 100644 index 00000000..9b954584 --- /dev/null +++ b/src/SharpMUTerm.Audio/SharpMUTerm.Audio.csproj @@ -0,0 +1,19 @@ + + + + SharpMUTerm.Audio + SharpMUTerm.Audio + + + + + + + + + + + + + + diff --git a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs index 92bca464..23f46530 100644 --- a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs +++ b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs @@ -67,6 +67,9 @@ public AvaloniaConsoleDriver(int columns, int rows) /// What the client says is under the pointer, for the window to show (). public WindowPointer Pointer { get; } = new(); + /// The window's screen-reader regions; the window adds them beside the view. + public WindowAnnouncer Announcer { get; } = new(); + public bool IsRunning { get; private set; } // ── IConsoleDriver: output ──────────────────────────────────────────────────────────────── diff --git a/src/SharpMUTerm.Avalonia/MainWindow.cs b/src/SharpMUTerm.Avalonia/MainWindow.cs index a14fffad..72326822 100644 --- a/src/SharpMUTerm.Avalonia/MainWindow.cs +++ b/src/SharpMUTerm.Avalonia/MainWindow.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Controls.ApplicationLifetimes; @@ -52,7 +53,16 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime // border stays, and the header's blank cells move the window (WindowChrome). --title-bar brings it back. WindowDecorations = options.TitleBar ? WindowDecorations.Full : WindowDecorations.BorderOnly; Background = new SolidColorBrush(Color.FromRgb(background.Red, background.Green, background.Blue)); - Content = _view; + // The view fills the window; the screen-reader regions sit under it, unseen. + var root = new Panel(); + foreach (var region in _driver.Announcer.Regions()) + { + root.Children.Add(region); + } + + root.Children.Add(_view); + Content = root; + AutomationProperties.SetName(_view, "SharpMUTerm"); var size = _view.SizeFor(options.Columns, options.Rows); Width = size.Width; Height = size.Height; diff --git a/src/SharpMUTerm.Avalonia/WindowAnnouncer.cs b/src/SharpMUTerm.Avalonia/WindowAnnouncer.cs new file mode 100644 index 00000000..1cb84ccf --- /dev/null +++ b/src/SharpMUTerm.Avalonia/WindowAnnouncer.cs @@ -0,0 +1,60 @@ +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Threading; +using SharpMUTerm.Core.Accessibility; + +namespace SharpMUTerm.Avalonia; + +/// +/// The window's half of : a live region a screen reader watches (UI Automation on Windows, +/// AT-SPI on Linux), which takes each announcement as its text. Output is polite, so a reader finishes what it was +/// saying; the client's own messages are assertive, because they are usually about the key just pressed. +/// +/// +/// The client calls on its own thread; the region is Avalonia's, so the text is posted to +/// Avalonia's thread. Nothing is kept: a reader that wants history has the transcript and the scrollback. +/// +internal sealed class WindowAnnouncer : IAnnouncer +{ + private TextBlock? _output; + private TextBlock? _notices; + + /// The two regions, built once for the window that shows them. + public IReadOnlyList Regions() + { + _output = Region("Output", AutomationLiveSetting.Polite); + _notices = Region("Client messages", AutomationLiveSetting.Assertive); + return [_output, _notices]; + } + + public void Announce(string text, AnnouncementKind kind) + { + var target = kind == AnnouncementKind.Output ? _output : _notices; + if (target is null) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + // The same text twice in a row is not a change, so a repeated line would go unspoken without this. + target.Text = target.Text == text ? text + " " : text; + }); + } + + /// A region a reader finds and a sighted user does not see: no size, no hit testing, never painted. + private static TextBlock Region(string name, AutomationLiveSetting live) + { + var block = new TextBlock + { + Width = 1, + Height = 1, + Opacity = 0, + IsHitTestVisible = false, + Focusable = false, + }; + AutomationProperties.SetName(block, name); + AutomationProperties.SetLiveSetting(block, live); + return block; + } +} diff --git a/src/SharpMUTerm.Avalonia/WindowHost.cs b/src/SharpMUTerm.Avalonia/WindowHost.cs index 7bd24956..4d55e597 100644 --- a/src/SharpMUTerm.Avalonia/WindowHost.cs +++ b/src/SharpMUTerm.Avalonia/WindowHost.cs @@ -46,5 +46,14 @@ internal static class WindowHost RunsInline = false, Pointer = driver.Pointer, SixelCell = SharpMUTermApp.SixelCellSize(null), + + // The same desktop device as the terminal: both are desktop processes on the same machine. + Audio = () => SharpMUTerm.Audio.DesktopAudio.TryCreate(), + + // Discord's IPC is a local socket either host can reach. + Presence = () => new SharpMUTerm.Core.Presence.DiscordIpc(), + + // A live region a screen reader watches; the terminal has none, and --plain is its answer. + Announcer = driver.Announcer, }; } diff --git a/src/SharpMUTerm.Avalonia/WindowPointer.cs b/src/SharpMUTerm.Avalonia/WindowPointer.cs index 64639a7c..590c910a 100644 --- a/src/SharpMUTerm.Avalonia/WindowPointer.cs +++ b/src/SharpMUTerm.Avalonia/WindowPointer.cs @@ -35,6 +35,8 @@ public void Hover(PointerTarget target) public void ShowMenu(System.Drawing.Point cell, IReadOnlyList items) => MenuRequested?.Invoke(cell, items); + public bool DrawsMenus => true; + public void Scrollbars(IReadOnlyList bars) { Volatile.Write(ref _bars, bars); diff --git a/src/SharpMUTerm.Core/Accessibility/IAnnouncer.cs b/src/SharpMUTerm.Core/Accessibility/IAnnouncer.cs new file mode 100644 index 00000000..8240e067 --- /dev/null +++ b/src/SharpMUTerm.Core/Accessibility/IAnnouncer.cs @@ -0,0 +1,23 @@ +namespace SharpMUTerm.Core.Accessibility; + +/// What an announcement is, so a host can speak or print each kind its own way. +public enum AnnouncementKind +{ + /// A line of output arriving in a window. + Output, + + /// A message from the client itself: a refusal, a confirmation, a warning. + Notice, + + /// Which window is now in front. + Focus, +} + +/// +/// Where a host sends text for a screen reader or for speech. A host supplies one or none; the client calls it on +/// its own thread, with plain text (no markup), and an implementation must not block. +/// +public interface IAnnouncer +{ + void Announce(string text, AnnouncementKind kind); +} diff --git a/src/SharpMUTerm.Core/Automation/AliasEngine.cs b/src/SharpMUTerm.Core/Automation/AliasEngine.cs index b5899dec..cc1b963f 100644 --- a/src/SharpMUTerm.Core/Automation/AliasEngine.cs +++ b/src/SharpMUTerm.Core/Automation/AliasEngine.cs @@ -101,6 +101,18 @@ public bool Remove(Alias alias) } } + /// + /// Drops what a script added and keeps what the configuration contributed: the half of a script + /// reload that stops the reloaded script's own registrations arriving twice. + /// + public void ClearRuntime() + { + lock (_gate) + { + _runtime.Clear(); + } + } + public void Clear() { lock (_gate) diff --git a/src/SharpMUTerm.Core/Automation/ScriptRequest.cs b/src/SharpMUTerm.Core/Automation/ScriptRequest.cs new file mode 100644 index 00000000..b7032f14 --- /dev/null +++ b/src/SharpMUTerm.Core/Automation/ScriptRequest.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; + +namespace SharpMUTerm.Core.Automation; + +/// Which kind of rule asked for a script callback. +public enum ScriptSource +{ + Trigger, + Alias, + Timer, + Macro, +} + +/// +/// A rule asking the scripting layer to run its callback: the callback's name, the text that matched and +/// its capture groups. Core only reports the request; it never runs Lua. +/// +public sealed record ScriptRequest(ScriptSource Source, string Callback, string WholeMatch, IReadOnlyList Groups) +{ + /// A request carrying a regex match's whole text and its numbered groups (group 0 excluded). + public static ScriptRequest FromMatch(ScriptSource source, string callback, Match match) + { + ArgumentNullException.ThrowIfNull(match); + var groups = new string[Math.Max(0, match.Groups.Count - 1)]; + for (var i = 1; i < match.Groups.Count; i++) + { + groups[i - 1] = match.Groups[i].Value; + } + + return new ScriptRequest(source, callback, match.Value, groups); + } + + /// A request with nothing matched, for a timer firing or a key being pressed. + public static ScriptRequest Bare(ScriptSource source, string callback) => + new(source, callback, string.Empty, Array.Empty()); +} diff --git a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs index e3cdde16..1a82bf27 100644 --- a/src/SharpMUTerm.Core/Automation/TriggerEngine.cs +++ b/src/SharpMUTerm.Core/Automation/TriggerEngine.cs @@ -199,6 +199,18 @@ public bool Remove(Trigger trigger) } } + /// + /// Drops what a script added and keeps what the configuration contributed: the half of a script + /// reload that stops the reloaded script's own registrations arriving twice. + /// + public void ClearRuntime() + { + lock (_gate) + { + _runtime.Clear(); + } + } + public void Clear() { lock (_gate) diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 3f49f66e..4ce9af1b 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -15,7 +15,8 @@ public sealed record CharacterRef( string CharacterName, string SessionKey, bool Connected, - bool Open = false); + bool Open = false, + bool InGroup = false); /// Live flags the catalog reads so stateful commands show their current value. /// Whether the focused character is logging. @@ -34,6 +35,17 @@ public sealed record CharacterRef( /// The chord that inserts a newline where the client is running — Alt+⏎ in a terminal, Ctrl+⏎ in /// the desktop window, which keeps ⌥⏎ for itself. Supplied by the host for the reason the screens are. /// +/// +/// Whether this client runs Lua at all, so Reload scripts is offered only where it can do something. +/// +/// Whether the focused connection is speedwalking, so Stop walking has a walk to stop. +/// +/// Null where the client cannot play sound, so no mute is offered; otherwise whether sound is playing +/// (offer Mute sound) or muted (offer Unmute sound). +/// +/// +/// Null where the host has no screen reader to tell; otherwise whether the active window's output is read aloud. +/// public sealed record CommandContext( bool LoggingOn = false, bool Zoomed = false, @@ -41,7 +53,11 @@ public sealed record CommandContext( bool TimestampsOn = false, bool SecondInputOn = false, bool ScrolledBack = false, - string NewlineChord = "Alt+⏎"); + string NewlineChord = "Alt+⏎", + bool ScriptsOn = false, + bool Walking = false, + bool? Sound = null, + bool? Announced = null); /// /// A configuration screen the command surface can open: what it calls itself, the id the host @@ -171,6 +187,47 @@ public static IReadOnlyList Build( // can find is the same as no feature — ⌃L's newline sat unused until it was reported missing. items.Add(new CommandItem(CommandGroup.World, "Reconnect", "world:reconnect", "⌥R")); items.Add(new CommandItem(CommandGroup.World, "Disconnect", "world:disconnect", "⌥D")); + // The send group: one line to several characters. Offered once two characters are open, since a + // group of one is the command line. + var open = characters.Where(c => c.Open).ToList(); + if (open.Count > 1 || characters.Any(c => c.InGroup)) + { + foreach (var character in open) + { + items.Add(new CommandItem( + CommandGroup.World, + character.InGroup + ? $"Remove {character.CharacterName} from the send group" + : $"Add {character.CharacterName} to the send group", + CommandIds.GroupToggle(character.SessionKey), + character.WorldName)); + } + + var members = characters.Count(c => c.InGroup); + if (members > 0) + { + items.Add(new CommandItem( + CommandGroup.World, + "Send the command line to the group", + CommandIds.GroupSend, + members == 1 ? "1 character" : $"{members} characters")); + items.Add(new CommandItem(CommandGroup.World, "Clear the send group", CommandIds.GroupClear)); + } + } + + if (context.ScriptsOn) + { + items.Add(new CommandItem( + CommandGroup.World, "Reload scripts", "world:reload-scripts", "re-reads this character's Lua files")); + } + + // Only where the host has a screen reader to tell: the window's live region, or --plain. + if (context.Announced is { } announced) + { + items.Add(announced + ? new CommandItem(CommandGroup.Terminal, "Stop reading this window aloud", "a11y:toggle-window") + : new CommandItem(CommandGroup.Terminal, "Read this window aloud", "a11y:toggle-window")); + } // TERMINAL — stateful labels. items.Add(context.LoggingOn @@ -217,6 +274,28 @@ public static IReadOnlyList Build( items.Add(new CommandItem( CommandGroup.Terminal, "Show client messages", "term:messages", "status-line notices")); + // What the server is sending out of band. Listed unconditionally: a meter or a script is written + // against a GMCP path, and this is where a player finds out which paths their game has. + items.Add(new CommandItem( + CommandGroup.Terminal, "Show game data", "term:gamedata", "GMCP and MSDP this connection holds")); + + // The map. Listed unconditionally, like the game data above: a map is recorded from the first room a + // game reports, and nothing else on screen says the client keeps one. + items.Add(new CommandItem( + CommandGroup.Terminal, "Show map", "term:map", "this connection's map · click a room, or /walk ")); + if (context.Walking) + { + items.Add(new CommandItem(CommandGroup.Terminal, "Stop walking", "term:walk-stop", "ends the speedwalk")); + } + + // Game sound, for this run. The levels and the off switch are F7's; this is the quick one. + if (context.Sound is { } playing) + { + items.Add(playing + ? new CommandItem(CommandGroup.Terminal, "Mute sound", "term:mute", "until unmuted or restarted") + : new CommandItem(CommandGroup.Terminal, "Unmute sound", "term:unmute", "game sound is muted")); + } + // The command line's own history, browsable and searchable. Named here as well as bound to ⌃R // because a chord nothing mentions is a chord nobody finds — the same reason every settings screen // has a row even though each has an F-key. @@ -228,6 +307,15 @@ public static IReadOnlyList Build( // at from the command line in front of them. items.Add(new CommandItem( CommandGroup.Terminal, "Compose a post", "term:compose", "F1 · a full editor, sent as one line")); + + // Packages: the list, and the install that starts at the review. F10 can be taken by a terminal's + // own menu key (GNOME Terminal), so this surface is the way in that always works. + items.Add(new CommandItem( + CommandGroup.Terminal, "Packages", "term:packages", "F10 · installed packages, on/off, remove")); + items.Add(new CommandItem( + CommandGroup.Terminal, "Install package…", "term:package-install", "from a file or an https link")); + items.Add(new CommandItem( + CommandGroup.Terminal, "Browse packages", "term:package-browse", "the package index, this game's first")); items.Add(context.TimestampsOn ? new CommandItem(CommandGroup.Terminal, "Hide timestamps", "term:timestamps-off") : new CommandItem(CommandGroup.Terminal, "Show timestamps", "term:timestamps-on")); diff --git a/src/SharpMUTerm.Core/Commands/CommandIds.cs b/src/SharpMUTerm.Core/Commands/CommandIds.cs index fe13a081..e842ef53 100644 --- a/src/SharpMUTerm.Core/Commands/CommandIds.cs +++ b/src/SharpMUTerm.Core/Commands/CommandIds.cs @@ -16,6 +16,18 @@ public static class CommandIds /// Prefix of a "go to this window" id; the remainder is a workspace window id. public const string WindowPrefix = "win:"; + /// Prefix of an "add to / remove from the send group" id; the remainder is a session key. + public const string GroupTogglePrefix = "group:toggle:"; + + /// Sends the command line to every member of the send group. + public const string GroupSend = "group:send"; + + /// Empties the send group. + public const string GroupClear = "group:clear"; + + /// The id that adds the character named by to the send group, or removes it. + public static string GroupToggle(string sessionKey) => GroupTogglePrefix + sessionKey; + /// The id that switches to the character named by . public static string Character(string sessionKey) => CharacterPrefix + sessionKey; diff --git a/src/SharpMUTerm.Core/Commands/CompletionIndex.cs b/src/SharpMUTerm.Core/Commands/CompletionIndex.cs new file mode 100644 index 00000000..6f64e08b --- /dev/null +++ b/src/SharpMUTerm.Core/Commands/CompletionIndex.cs @@ -0,0 +1,185 @@ +using System.Text.Json; + +namespace SharpMUTerm.Core.Commands; + +/// +/// The words ⇥ can complete on the command line: the people the game has named over GMCP first, then the +/// words of recent output, newest first. One per connection, fed on the read loop and asked on the UI +/// thread, so every member takes the same lock. +/// +public sealed class CompletionIndex +{ + /// How many distinct words of output are kept; the oldest goes first. + public const int MaxWords = 4096; + + /// How many names are kept. + public const int MaxNames = 512; + + /// A word shorter than this is quicker typed than completed. + public const int MinWordLength = 3; + + /// A longer run is not a word anybody types. + public const int MaxWordLength = 32; + + private readonly Lock _gate = new(); + private readonly Recency _words = new(MaxWords); + private readonly Recency _names = new(MaxNames); + + /// Takes the words of one line of output. + public void ObserveLine(string text) + { + ArgumentNullException.ThrowIfNull(text); + lock (_gate) + { + foreach (var word in Words(text)) + { + _words.Touch(word); + } + } + } + + /// Takes a name the game said is a person: in the room, on a channel, talking. + public void ObserveName(string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > MaxWordLength || name.Any(char.IsWhiteSpace)) + { + return; + } + + lock (_gate) + { + _names.Touch(name); + } + } + + /// + /// Reads the names out of the GMCP messages that carry them: Room.Players, Room.AddPlayer, + /// Comm.Channel.Players and Comm.Channel.Text's talker. Anything else, or anything malformed, + /// is ignored. + /// + public void ObserveGmcp(string package, string json) + { + if (package is not ("Room.Players" or "Room.AddPlayer" or "Comm.Channel.Players" or "Comm.Channel.Text")) + { + return; + } + + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + switch (package) + { + case "Comm.Channel.Text": + ObserveName(Property(root, "talker")); + break; + case "Room.AddPlayer": + ObserveName(Property(root, "name")); + break; + default: + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray().Take(MaxNames)) + { + ObserveName(item.ValueKind == JsonValueKind.String ? item.GetString() : Property(item, "name")); + } + } + + break; + } + } + catch (JsonException) + { + } + } + + /// + /// What completes to, best first: names, then words, each newest first, matched + /// without regard to case and never the prefix itself. + /// + public IReadOnlyList Candidates(string prefix) + { + ArgumentNullException.ThrowIfNull(prefix); + if (prefix.Length == 0) + { + return []; + } + + lock (_gate) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { prefix }; + return _names.Newest().Concat(_words.Newest()) + .Where(w => w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && seen.Add(w)) + .ToList(); + } + } + + /// Forgets everything, as a new connection does. + public void Clear() + { + lock (_gate) + { + _words.Clear(); + _names.Clear(); + } + } + + /// The runs of letters, digits, _, ' and - in a line, trimmed of trailing punctuation. + internal static IEnumerable Words(string text) + { + var start = -1; + for (var i = 0; i <= text.Length; i++) + { + var inWord = i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] is '_' or '\'' or '-'); + if (inWord && start < 0) + { + start = i; + } + else if (!inWord && start >= 0) + { + var word = text[start..i].TrimEnd('\'', '-').TrimStart('\'', '-'); + start = -1; + if (word.Length is >= MinWordLength and <= MaxWordLength && word.Any(char.IsLetter)) + { + yield return word; + } + } + } + } + + private static string? Property(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object + && element.EnumerateObject().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) is { Value.ValueKind: JsonValueKind.String } found + ? found.Value.GetString() + : null; + + /// A capped set kept in order of last use, holding each word in the casing it was last seen in. + private sealed class Recency(int capacity) + { + private readonly LinkedList _order = new(); + private readonly Dictionary> _nodes = new(StringComparer.OrdinalIgnoreCase); + + public void Touch(string word) + { + if (_nodes.Remove(word, out var node)) + { + _order.Remove(node); + } + + _nodes[word] = _order.AddFirst(word); + if (_nodes.Count > capacity && _order.Last is { } last) + { + _nodes.Remove(last.Value); + _order.RemoveLast(); + } + } + + public IEnumerable Newest() => _order; + + public void Clear() + { + _order.Clear(); + _nodes.Clear(); + } + } +} diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs index 343c3e01..6d1aeda1 100644 --- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs +++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs @@ -78,6 +78,9 @@ public sealed class AppConfiguration /// How inbound text is drawn — the F7 "Text & ANSI" screen's settings. public TextSettings Text { get; set; } = new(); + /// Game sound: on or off, the player's levels, captions (F7 ▸ SOUND). + public SoundSettings Sound { get; set; } = new(); + /// How the command line behaves — the F8 "Input & spellcheck" screen's settings. public InputSettings Input { get; set; } = new(); @@ -91,6 +94,24 @@ public sealed class AppConfiguration /// public List TriggerSets { get; set; } = new(); + /// + /// The installed packages, read from their folders at start () and never + /// saved here: a package's sets are its own, replaced wholesale on update, and the screens that edit + /// do not list them. They apply to the characters of the worlds each package is + /// for, after the character's own sets. + /// + [System.Text.Json.Serialization.JsonIgnore] + public IReadOnlyList Packages { get; set; } = []; + + /// The ids of installed packages the player has switched off; their sets apply to nobody. + public List DisabledPackages { get; set; } = new(); + + /// The package index Browse packages reads: an https link to an index.json. + public string PackageIndexUrl { get; set; } = DefaultPackageIndexUrl; + + /// The project's own index, until a player names another. + public const string DefaultPackageIndexUrl = "https://raw.githubusercontent.com/SharpMUSH/sharpmuterm-packages/main/index.json"; + /// /// The last workspace layout (panes, windows, focus) so the app can resume where it left off. /// Null on a fresh config; the shell rebuilds a live workspace from it at startup via @@ -104,9 +125,10 @@ public sealed class AppConfiguration /// /// Resolves the s a character has opted into, in the character's own /// order, matching by name case-insensitively. Names with no matching set are skipped, and - /// each set is returned at most once even if referenced twice. + /// each set is returned at most once even if referenced twice. With , the sets of + /// every enabled package for that world follow. /// - public IReadOnlyList ResolveTriggerSets(CharacterDefinition character) + public IReadOnlyList ResolveTriggerSets(CharacterDefinition character, WorldDefinition? world = null) { ArgumentNullException.ThrowIfNull(character); @@ -126,6 +148,17 @@ public IReadOnlyList ResolveTriggerSets(CharacterDefinition characte } } + if (world is not null) + { + foreach (var package in Packages) + { + if (!DisabledPackages.Contains(package.Id, StringComparer.Ordinal) && package.Manifest.IsFor(world.Host, world.Port)) + { + resolved.AddRange(package.Sets); + } + } + } + return resolved; } } diff --git a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs index 2aa33342..57f5ce8f 100644 --- a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs @@ -106,7 +106,10 @@ public sealed class CharacterDefinition /// Semicolon-separated commands sent after connecting. public string? OnConnect { get; set; } - /// Semicolon-separated commands sent (or run locally) on disconnect. + /// + /// Semicolon-separated commands sent before the client closes this character's connection itself + /// (Disconnect, Reconnect, quitting) — QUIT, typically. A connection the server dropped gets none. + /// public string? OnDisconnect { get; set; } /// Names of the s that apply to this character. diff --git a/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs b/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs index a47ea39f..81ff7239 100644 --- a/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs +++ b/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs @@ -161,6 +161,13 @@ public sealed class TextSettings /// Ten minutes. Past this a boundary marker is not a marker, it is a pin. public const int MaxActivityBarSeconds = 600; + /// + /// The readings the status row shows from the session's GMCP and MSDP data (F7 ▸ STATUS ROW). A meter + /// whose value the server has not sent is not drawn, so the defaults cost nothing on a world without + /// either protocol. + /// + public List StatusMeters { get; set; } = StatusMeter.Defaults(); + // There is deliberately no "ambiguous width" here. It was a setting with nothing behind it: every // column measurement in this app is SharpConsoleUI's (Helpers/UnicodeWidth.cs), which asks the // Wcwidth tables and offers no East-Asian-ambiguous policy to set. Honouring it needs an upstream @@ -219,3 +226,35 @@ public sealed class InputSettings /// public bool SecondBar { get; set; } } + +/// +/// Game sound: whether it plays at all, the player's three levels, and whether a caption line is printed for +/// each sound. Edited on F7's SOUND rows. Levels are 0 to 100 and multiply: a sound's own volume, then its +/// kind's, then the master. +/// +public sealed class SoundSettings +{ + public const int MaxVolume = 100; + + /// + /// Whether game sound plays. Off also stops the client telling a GMCP game it can play media, so a game + /// that asks first sends none. + /// + public bool Enabled { get; set; } = true; + + public int MasterVolume { get; set; } = MaxVolume; + + public int SoundVolume { get; set; } = MaxVolume; + + public int MusicVolume { get; set; } = MaxVolume; + + /// + /// Print a dim ♪ caption line in the character's window when a sound starts: the game's caption, or + /// the file's name. For a player who has sound off or cannot hear it. + /// + public bool Captions { get; set; } + + /// The levels as a queue mix; is the session's ⌃P mute. + public Media.MediaMix Mix(bool muted) => + new(MasterVolume, SoundVolume, MusicVolume, Muted: muted || !Enabled); +} diff --git a/src/SharpMUTerm.Core/Configuration/StatusMeter.cs b/src/SharpMUTerm.Core/Configuration/StatusMeter.cs new file mode 100644 index 00000000..a102bd2f --- /dev/null +++ b/src/SharpMUTerm.Core/Configuration/StatusMeter.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using System.Text; +using SharpMUTerm.Core.Protocols; + +namespace SharpMUTerm.Core.Configuration; + +/// +/// One reading on the status row: a short label and the game data it shows, optionally out of a maximum — +/// hp 450/500. Each path may list alternatives separated by |, tried in order, so one meter reads +/// a GMCP game and an MSDP game alike. +/// +public sealed class StatusMeter +{ + /// What the row prints before the value. Kept short; it is clipped to . + public string Label { get; set; } = string.Empty; + + /// The value's path (Char.Vitals.hp|MSDP.HEALTH). + public string Value { get; set; } = string.Empty; + + /// The maximum's path, or null for a meter with none (gold). + public string? Max { get; set; } + + /// The longest label the row draws. + public const int MaxLabelLength = 8; + + /// A label cut to . + public static string Clip(string label) => label.Length > MaxLabelLength ? label[..MaxLabelLength] : label; + + /// + /// The default row: health, mana and movement, by the names IRE, Aardwolf and the MSDP convention use. + /// + public static List Defaults() => + [ + new() + { + Label = "hp", + Value = "Char.Vitals.hp|MSDP.HEALTH", + Max = "Char.Vitals.maxhp|Char.MaxStats.maxhp|MSDP.HEALTH_MAX", + }, + new() + { + Label = "mp", + Value = "Char.Vitals.mp|Char.Vitals.mana|MSDP.MANA", + Max = "Char.Vitals.maxmp|Char.MaxStats.maxmana|MSDP.MANA_MAX", + }, + new() + { + Label = "mv", + Value = "Char.Vitals.moves|MSDP.MOVEMENT", + Max = "Char.MaxStats.maxmoves|MSDP.MOVEMENT_MAX", + }, + ]; + + /// The first alternative of the store holds a value for, as text. + public static string? Read(GameDataStore data, string? paths) + { + foreach (var path in Alternatives(paths)) + { + if (data.GetText(path) is { } text) + { + return text; + } + } + + return null; + } + + /// The alternatives in a path list, trimmed, blanks dropped. + public static IEnumerable Alternatives(string? paths) => + (paths ?? string.Empty).Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + /// + /// The MSDP variables a list of meters reads — what a session asks an MSDP server to REPORT. + /// + public static IReadOnlyList MsdpVariables(IEnumerable meters) => meters + .SelectMany(m => Alternatives(m.Value).Concat(Alternatives(m.Max))) + .Where(p => p.StartsWith(GameDataStore.MsdpPrefix + ".", StringComparison.OrdinalIgnoreCase)) + .Select(p => p[(GameDataStore.MsdpPrefix.Length + 1)..]) + .Where(v => v.Length > 0 && !v.Contains('.', StringComparison.Ordinal)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + /// + /// Writes meters as the one-line form F7 edits: hp=Char.Vitals.hp/Char.Vitals.maxhp; gold=Char.Status.gold. + /// + public static string Format(IEnumerable meters) + { + var sb = new StringBuilder(); + foreach (var meter in meters) + { + if (sb.Length > 0) + { + sb.Append("; "); + } + + sb.Append(meter.Label).Append('=').Append(meter.Value); + if (!string.IsNullOrWhiteSpace(meter.Max)) + { + sb.Append('/').Append(meter.Max); + } + } + + return sb.ToString(); + } + + /// + /// Reads the one-line form back. A part with no =, no label or no value is skipped, so a half-typed + /// entry costs nothing; the label is clipped to . + /// + public static List Parse(string? text) + { + var meters = new List(); + foreach (var part in (text ?? string.Empty).Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var eq = part.IndexOf('=', StringComparison.Ordinal); + if (eq <= 0) + { + continue; + } + + var label = part[..eq].Trim(); + var paths = part[(eq + 1)..]; + var slash = paths.IndexOf('/', StringComparison.Ordinal); + var value = (slash < 0 ? paths : paths[..slash]).Trim(); + var max = slash < 0 ? null : paths[(slash + 1)..].Trim(); + if (label.Length == 0 || value.Length == 0) + { + continue; + } + + meters.Add(new StatusMeter + { + Label = label.Length > MaxLabelLength ? label[..MaxLabelLength] : label, + Value = value, + Max = string.IsNullOrEmpty(max) ? null : max, + }); + } + + return meters; + } + + /// + /// A reading fitted to cells without wrapping a number past recognition: a whole + /// number too long is shown in thousands or millions (123k, 12M), anything else is clipped + /// with an ellipsis. Never longer than the width, which is what lets the row reserve a fixed field. + /// + public static string Fit(string text, int width) + { + if (text.Length <= width) + { + return text; + } + + if (long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) + { + foreach (var (scale, suffix) in new[] { (1_000L, "k"), (1_000_000L, "M"), (1_000_000_000L, "G") }) + { + var scaled = (n / scale).ToString(CultureInfo.InvariantCulture) + suffix; + if (scaled.Length <= width) + { + return scaled; + } + } + } + + return width <= 1 ? "…" : text[..(width - 1)] + "…"; + } +} diff --git a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs index dd8f956f..b7787f89 100644 --- a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs @@ -15,7 +15,10 @@ public enum LogFormat /// How a world's inbound text is interpreted for styling and interaction. public enum ContentFormat { - /// Plain text with ANSI SGR colour (the default). + /// + /// Plain text with ANSI SGR colour, switched to MXP when the connection negotiates it — the default, and + /// so it means "nobody chose" (F5's auto). + /// Ansi, /// MXP markup (tags, SEND/A links) over ANSI. @@ -23,6 +26,13 @@ public enum ContentFormat /// Pueblo HTML-subset markup. Pueblo, + + /// + /// ANSI colour and nothing else, chosen on purpose: a negotiated MXP does not switch the parser (F5's + /// ansi). Appended, because the configuration stores these by name and has to + /// keep meaning what every saved world already means by it. + /// + AnsiOnly, } /// Emoji/emoticon substitution options. @@ -35,6 +45,34 @@ public sealed class EmojiSettings public bool Shortcodes { get; set; } = true; } +/// +/// The mapper's per-world text patterns, for games that send neither GMCP room data nor MXP room tags. Both +/// are regular expressions; empty means the text source is off. +/// +public sealed class MapSettings +{ + /// + /// Matches the line that names a room. The name group, when there is one, is the name; otherwise the + /// whole match is. + /// + public string? RoomNamePattern { get; set; } + + /// + /// Matches the line that lists a room's exits, and so ends the room. The exits group holds the list, + /// split on spaces, commas and and. + /// + public string? ExitsPattern { get; set; } + + /// + /// Matches a line saying a step of a speedwalk did not happen (You can't go that way.), which stops + /// the walk at once rather than after its step timeout. Null leaves the timeout as the only stop. + /// + public string? WalkFailurePattern { get; set; } + + public MapSettings Clone() => + new() { RoomNamePattern = RoomNamePattern, ExitsPattern = ExitsPattern, WalkFailurePattern = WalkFailurePattern }; +} + /// Per-character logging configuration. public sealed class LoggingSettings { @@ -106,6 +144,43 @@ public sealed class WorldDefinition /// Emoji/emoticon substitution for inbound text. public EmojiSettings Emoji { get; set; } = new(); + /// + /// GMCP modules to ask this world for beyond the client's defaults, each Name Version + /// (IRE.Rift 1) — sent in Core.Supports.Set once the server's GMCP is agreed. + /// + public List GmcpModules { get; set; } = new(); + + /// + /// Whether to log in over GMCP's Char.Login on a server that offers it, rather than by sending the + /// connect line. Off by default. On, the client asks for Char.Login 1 and waits a few seconds for the + /// server's Char.Login.Default; a server that offers password-credentials is sent the + /// character's name and saved password, and any other answer, or none, gets the connect line as before. + /// + public bool GmcpLogin { get; set; } + + /// + /// Whether this world may show what you are doing on your Discord profile, through GMCP's + /// External.Discord. Off by default. On, the world is asked for External.Discord 1 and what it + /// sends goes to the Discord app on this computer while one of its characters' windows is in front. + /// + public bool DiscordPresence { get; set; } + + /// How the mapper recognises a room on a game that sends no room data (see ). + public MapSettings Map { get; set; } = new(); + + /// + /// Whether the client may download the sound files this world asks for (MCMP, MSP). Off still plays what is + /// already cached or in a soundpack folder; it only stops new fetches. + /// + public bool DownloadMedia { get; set; } = true; + + /// + /// The prefix a MUSH starts a line with when it hands text back to edit — AresMUSH's FugueEdit > — + /// or null for none, the default. Such a line is not shown: its text is put on the command line, and the + /// player sends it. Never sent by the client, because another player can page a line that starts with it. + /// + public string? GrabPrefix { get; set; } + /// /// The world's accent colour, used to keep its windows traceable to their owner once they /// scatter across panes. Default resolves to a theme-derived accent. @@ -138,6 +213,12 @@ public sealed class WorldDefinition Shortcodes = Emoji.Shortcodes, }, Accent = Accent, + GmcpModules = GmcpModules.ToList(), + Map = Map.Clone(), + DownloadMedia = DownloadMedia, + GrabPrefix = GrabPrefix, + GmcpLogin = GmcpLogin, + DiscordPresence = DiscordPresence, Characters = Characters.Select(c => c.Clone()).ToList(), }; diff --git a/src/SharpMUTerm.Core/Mapping/GmcpRoomSource.cs b/src/SharpMUTerm.Core/Mapping/GmcpRoomSource.cs new file mode 100644 index 00000000..acdd92fa --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/GmcpRoomSource.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Telnet; +using TelnetNegotiationCore.Gmcp; + +namespace SharpMUTerm.Core.Mapping; + +/// +/// Rooms from GMCP Room.Info. Servers disagree on its shape, so the shape is read off what is present: +/// +/// Iron Realms and Aardwolf: num, name, area/zone, +/// environment/terrain, coords as area,x,y,z (IRE) or coord as an object +/// (Aardwolf), and exits as direction → room number. Read through TelnetNegotiationCore's typed +/// . +/// MUME and its relatives: id instead of num, exits as objects carrying +/// id, and Room.UpdateExits to change the exits of the room the character is in. +/// +/// The package name is matched without regard to case, because Aardwolf sends room.info. +/// +public sealed class GmcpRoomSource : IRoomSource +{ + private readonly WorldSession _session; + + public GmcpRoomSource(WorldSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _session.GmcpReceived += OnGmcp; + } + + public event EventHandler? RoomEntered; + + public void Dispose() => _session.GmcpReceived -= OnGmcp; + + private void OnGmcp(object? sender, GmcpMessageEventArgs e) + { + if (Read(e.Package, e.Json) is { } room) + { + RoomEntered?.Invoke(this, room); + } + } + + /// The room a GMCP message describes, or null when it is not a room message or not one this reads. + public static RoomObservation? Read(string package, string json) + { + var isInfo = string.Equals(package, GmcpPackages.RoomInfo, StringComparison.OrdinalIgnoreCase); + var isUpdate = string.Equals(package, "Room.UpdateExits", StringComparison.OrdinalIgnoreCase); + if (!isInfo && !isUpdate) + { + return null; + } + + JsonObject? body; + try + { + body = JsonNode.Parse(json) as JsonObject; + } + catch (JsonException) + { + return null; + } + + if (body is null) + { + return null; + } + + if (isUpdate) + { + // MUME sends the exits object itself, or wrapped in "exits". + var exits = Exits(body["exits"] as JsonObject ?? body); + return new RoomObservation(Number(body["id"]), string.Empty, exits) { ExitsOnly = true }; + } + + if (body["num"] is not null && RoomInfo.TryParse(json, out var ire)) + { + return new RoomObservation(ire.Num, ire.Name, Exits(body["exits"] as JsonObject)) + { + Area = ire.Area, + Environment = ire.Environment, + Coordinates = IreCoordinates(ire.Coords) ?? AardwolfCoordinates(body["coord"] as JsonObject), + }; + } + + if (Number(body["id"]) is { } id) + { + return new RoomObservation(id, Text(body["name"]) ?? string.Empty, Exits(body["exits"] as JsonObject)) + { + Area = Text(body["area"]) ?? Text(body["zone"]), + Environment = Text(body["terrain"]) ?? Text(body["environment"]), + Coordinates = Number(body["x"]) is { } x && Number(body["y"]) is { } y + ? ((int)x, (int)y, (int)(Number(body["z"]) ?? 0)) + : null, + }; + } + + return null; + } + + /// + /// Exits by direction or command, where each value is a room number, a numeric string, or an object + /// carrying one under id (MUME). A direction is stored by its long name; anything else is a command. + /// + private static List Exits(JsonObject? exits) + { + var observed = new List(); + if (exits is null) + { + return observed; + } + + foreach (var (key, value) in exits) + { + var to = value is JsonObject target ? Number(target["id"]) : Number(value); + observed.Add(new ObservedExit(MapDirections.Normalise(key) ?? key, to)); + } + + return observed; + } + + /// IRE's coords: area,x,y,z, or x,y,z. + private static (int, int, int)? IreCoordinates(string? coords) + { + var parts = (coords ?? string.Empty).Split(',', StringSplitOptions.TrimEntries); + var numbers = new List(); + foreach (var part in parts) + { + if (!int.TryParse(part, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) + { + return null; + } + + numbers.Add(n); + } + + return numbers.Count switch + { + >= 4 => (numbers[1], numbers[2], numbers[3]), + 3 => (numbers[0], numbers[1], numbers[2]), + _ => null, + }; + } + + /// Aardwolf's coord: {"id":…,"x":…,"y":…,"cont":…}, flat on one level. + private static (int, int, int)? AardwolfCoordinates(JsonObject? coord) => + coord is not null && Number(coord["x"]) is { } x && Number(coord["y"]) is { } y && (x >= 0 || y >= 0) + ? ((int)x, (int)y, 0) + : null; + + private static long? Number(JsonNode? node) + { + if (node is not JsonValue value) + { + return null; + } + + if (value.TryGetValue(out var n)) + { + return n; + } + + if (value.TryGetValue(out var d) && d == Math.Floor(d) && Math.Abs(d) < long.MaxValue) + { + return (long)d; + } + + return value.TryGetValue(out var s) + && long.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private static string? Text(JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var s) ? s : null; +} diff --git a/src/SharpMUTerm.Core/Mapping/MapDirections.cs b/src/SharpMUTerm.Core/Mapping/MapDirections.cs new file mode 100644 index 00000000..d74d5cb7 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/MapDirections.cs @@ -0,0 +1,80 @@ +namespace SharpMUTerm.Core.Mapping; + +/// +/// The twelve directions a map knows, by their long names (north, up, in), with the +/// short command each is walked by, its reverse, and the grid step it takes. Every exit key a +/// holds is one of ; anything else is a special exit. +/// +public static class MapDirections +{ + /// The directions in the order a room's exits are listed. + public static readonly IReadOnlyList All = + ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "up", "down", "in", "out"]; + + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["n"] = "north", ["ne"] = "northeast", ["e"] = "east", ["se"] = "southeast", + ["s"] = "south", ["sw"] = "southwest", ["w"] = "west", ["nw"] = "northwest", + ["u"] = "up", ["d"] = "down", + }; + + private static readonly Dictionary Short = new(StringComparer.Ordinal) + { + ["north"] = "n", ["northeast"] = "ne", ["east"] = "e", ["southeast"] = "se", + ["south"] = "s", ["southwest"] = "sw", ["west"] = "w", ["northwest"] = "nw", + ["up"] = "u", ["down"] = "d", ["in"] = "in", ["out"] = "out", + }; + + private static readonly Dictionary Reverses = new(StringComparer.Ordinal) + { + ["north"] = "south", ["northeast"] = "southwest", ["east"] = "west", ["southeast"] = "northwest", + ["south"] = "north", ["southwest"] = "northeast", ["west"] = "east", ["northwest"] = "southeast", + ["up"] = "down", ["down"] = "up", ["in"] = "out", ["out"] = "in", + }; + + /// + /// The long name for (n, North and north all give + /// north), or null when it is not a direction. + /// + public static string? Normalise(string? direction) + { + var trimmed = direction?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return null; + } + + if (Aliases.TryGetValue(trimmed, out var full)) + { + return full; + } + + var lower = trimmed.ToLowerInvariant(); + return Short.ContainsKey(lower) ? lower : null; + } + + /// The command that walks : n for north. + public static string Command(string direction) => Short.GetValueOrDefault(direction, direction); + + /// The opposite direction, or null for one that is not a direction. + public static string? Reverse(string direction) => Reverses.GetValueOrDefault(direction); + + /// + /// The grid step a direction takes, with north as −y (rows grow downwards on screen) and up as +z. + /// in and out take none: they lead somewhere, but not anywhere a grid can say. + /// + public static (int Dx, int Dy, int Dz) Offset(string direction) => direction switch + { + "north" => (0, -1, 0), + "northeast" => (1, -1, 0), + "east" => (1, 0, 0), + "southeast" => (1, 1, 0), + "south" => (0, 1, 0), + "southwest" => (-1, 1, 0), + "west" => (-1, 0, 0), + "northwest" => (-1, -1, 0), + "up" => (0, 0, 1), + "down" => (0, 0, -1), + _ => (0, 0, 0), + }; +} diff --git a/src/SharpMUTerm.Core/Mapping/MapModel.cs b/src/SharpMUTerm.Core/Mapping/MapModel.cs new file mode 100644 index 00000000..4e3ee74c --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/MapModel.cs @@ -0,0 +1,124 @@ +namespace SharpMUTerm.Core.Mapping; + +/// +/// One room of a map — Mudlet's model, which players already understand: an id the game gives it, a place +/// on a grid, exits by direction and by command, and the weights and locks the path finder reads. +/// +public sealed class MapRoom +{ + /// The game's own number for the room (GMCP num/id), or one the client assigned. + public long Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// The the room belongs to, or null. + public int? AreaId { get; set; } + + public int X { get; set; } + + public int Y { get; set; } + + public int Z { get; set; } + + /// The game's terrain or environment name, when it sends one. + public string? Environment { get; set; } + + /// Exits by direction (a name) → the room they lead to. + public Dictionary Exits { get; set; } = new(StringComparer.Ordinal); + + /// Exits by the command that takes them (enter portal) → the room they lead to. + public Dictionary SpecialExits { get; set; } = new(StringComparer.Ordinal); + + /// + /// Exits the room is known to have whose destination nobody has walked yet — Mudlet's stubs. The text + /// source reports only these; a walk turns one into an entry. + /// + public HashSet Stubs { get; set; } = new(StringComparer.Ordinal); + + /// The cost of entering this room, at least 1. A heavier room is walked around when there is a way. + public int Weight { get; set; } = 1; + + /// + /// The cost of a particular exit, by direction or command, overriding the destination room's + /// when set — Mudlet's rule. + /// + public Dictionary ExitWeights { get; set; } = new(StringComparer.Ordinal); + + /// Exits (directions or commands) the path finder never takes. + public HashSet LockedExits { get; set; } = new(StringComparer.Ordinal); + + /// A locked room is never walked through or to. + public bool Locked { get; set; } + + /// One or two characters drawn in the room's cell, or null. + public string? Symbol { get; set; } + + /// The room's colour as #rrggbb, or null for the default. + public string? Color { get; set; } + + /// Anything a script wants to keep on the room. + public Dictionary UserData { get; set; } = new(StringComparer.Ordinal); + + /// Every exit, direction and special alike, as the command that takes it and where it leads. + public IEnumerable<(string Command, string Key, long To)> AllExits() + { + foreach (var (direction, to) in Exits) + { + yield return (MapDirections.Command(direction), direction, to); + } + + foreach (var (command, to) in SpecialExits) + { + yield return (command, command, to); + } + } +} + +/// A named area rooms belong to. +public sealed class MapArea +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} + +/// +/// A world's map: its rooms and areas. Not thread-safe; the client touches it from the UI thread only. +/// +public sealed class WorldMap +{ + public Dictionary Rooms { get; } = new(); + + public Dictionary Areas { get; } = new(); + + /// The room, or null when the map has none by that id. + public MapRoom? Room(long id) => Rooms.GetValueOrDefault(id); + + /// Adds the room, or replaces the one with its id. + public MapRoom Add(MapRoom room) + { + ArgumentNullException.ThrowIfNull(room); + Rooms[room.Id] = room; + return room; + } + + /// The area with this name, created with the next free id when there is none. + public MapArea AreaNamed(string name) + { + foreach (var area in Areas.Values) + { + if (string.Equals(area.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return area; + } + } + + var created = new MapArea { Id = Areas.Count == 0 ? 1 : Areas.Keys.Max() + 1, Name = name }; + Areas[created.Id] = created; + return created; + } + + /// Whether a room other than already sits at this point of this area. + public bool IsOccupied(int? areaId, int x, int y, int z, long except) => + Rooms.Values.Any(r => r.Id != except && r.AreaId == areaId && r.X == x && r.Y == y && r.Z == z); +} diff --git a/src/SharpMUTerm.Core/Mapping/MapRecorder.cs b/src/SharpMUTerm.Core/Mapping/MapRecorder.cs new file mode 100644 index 00000000..7ffcc766 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/MapRecorder.cs @@ -0,0 +1,326 @@ +namespace SharpMUTerm.Core.Mapping; + +/// +/// Builds a map from where the character goes: every a source reports adds or +/// updates a room, links it to the room it was walked to from, and places it on the grid. +/// +/// The way a room was left is the last movement command sent (): a direction, +/// or a command one of the current room's special exits is taken by. Anything else is not a move and leaves +/// it alone, so say hi between n and the room that answers it changes nothing. +/// +/// +/// Placement, for a room the server gave no coordinates: one step from the room it was walked from, in +/// the direction walked (; up and down change z), pushed further along that +/// direction while the cell is taken. A room reached by in, out or a special exit has no +/// direction to step in, so it is put in the nearest free cell around the room before it. Coordinates a +/// server sends are taken as they are, every time. +/// +/// +/// A room with no number (the text source) is the room the walked exit already leads to when the map +/// knows one, the current room again when nothing was walked and the name agrees, and otherwise a new room +/// numbered past every id the map holds. +/// +/// +public sealed class MapRecorder : IDisposable +{ + private readonly IReadOnlyList _sources; + private string? _walked; + private bool _touched; + + public MapRecorder(WorldMap map, params IRoomSource[] sources) + { + Map = map ?? throw new ArgumentNullException(nameof(map)); + _sources = sources; + foreach (var source in _sources) + { + source.RoomEntered += OnRoom; + } + } + + public WorldMap Map { get; } + + /// The room the character is in, or null before the first observation. + public long? CurrentRoomId { get; private set; } + + public MapRoom? CurrentRoom => CurrentRoomId is { } id ? Map.Room(id) : null; + + /// Whether the map has changed since the last . + public bool IsDirty { get; private set; } + + /// Raised after an observation has been applied: the character moved, or the room changed. + public event EventHandler? Changed; + + public void MarkSaved() => IsDirty = false; + + /// + /// Held while the map is being changed. The client changes it only on its UI thread, so that thread needs no + /// lock to read it; a script, which runs elsewhere, takes this to read a consistent map. It is never held + /// while is raised, so a handler that ends up waiting on a script cannot deadlock. + /// + public object SyncRoot { get; } = new(); + + public void Dispose() + { + foreach (var source in _sources) + { + source.RoomEntered -= OnRoom; + source.Dispose(); + } + } + + /// + /// A command the player sent. A direction, or a special exit of the current room (linked or still a stub), + /// is the next move. + /// + public void NoteCommand(string command) + { + lock (SyncRoot) + { + NoteCommandCore(command); + } + } + + private void NoteCommandCore(string command) + { + var trimmed = command.Trim(); + if (MapDirections.Normalise(trimmed) is { } direction) + { + _walked = direction; + } + else if (CurrentRoom is { } room && (room.SpecialExits.ContainsKey(trimmed) || room.Stubs.Contains(trimmed))) + { + _walked = trimmed; + } + } + + /// + /// Where observations are applied. Sources raise on the telnet read loop; the client sets this to its UI + /// thread so the map is only ever touched there. Null applies in place, which is what a test wants. + /// + public Action? Post { get; init; } + + private void OnRoom(object? sender, RoomObservation room) + { + if (Post is { } post) + { + post(() => Apply(room)); + } + else + { + Apply(room); + } + } + + /// A change made by hand (a room locked, say): applied under the lock, saved, and redrawn. + public void Edit(Action change) + { + ArgumentNullException.ThrowIfNull(change); + lock (SyncRoot) + { + change(Map); + IsDirty = true; + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + /// Applies one observation. Public so a test, or a script, can drive the recorder directly. + public void Apply(RoomObservation observation) + { + ArgumentNullException.ThrowIfNull(observation); + lock (SyncRoot) + { + _touched = false; + ApplyCore(observation); + } + + if (_touched) + { + _touched = false; + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private void ApplyCore(RoomObservation observation) + { + if (observation.ExitsOnly) + { + if (Map.Room(observation.Id ?? CurrentRoomId ?? long.MinValue) is { } here) + { + SetExits(here, observation.Exits); + Touch(); + } + + return; + } + + var previous = CurrentRoom; + var walked = _walked; + _walked = null; + + var id = observation.Id ?? Identify(observation, previous, walked); + var room = Map.Room(id); + var created = room is null; + room ??= Map.Add(new MapRoom { Id = id }); + + if (observation.Name.Length > 0) + { + room.Name = observation.Name; + } + + if (!string.IsNullOrWhiteSpace(observation.Area)) + { + room.AreaId = Map.AreaNamed(observation.Area).Id; + } + else if (created && previous is not null) + { + room.AreaId = previous.AreaId; + } + + room.Environment = observation.Environment ?? room.Environment; + SetExits(room, observation.Exits); + + if (observation.Coordinates is { } at) + { + (room.X, room.Y, room.Z) = at; + } + else if (created) + { + Place(room, previous, walked); + } + + if (previous is not null && walked is not null && previous.Id != room.Id) + { + Link(previous, walked, room); + } + + CurrentRoomId = room.Id; + Touch(); + } + + private void Touch() + { + IsDirty = true; + _touched = true; + } + + private long Identify(RoomObservation observation, MapRoom? previous, string? walked) + { + if (previous is not null) + { + if (walked is not null + && (previous.Exits.TryGetValue(walked, out var known) || previous.SpecialExits.TryGetValue(walked, out known)) + && Map.Room(known) is not null) + { + return known; + } + + if (walked is null && string.Equals(previous.Name, observation.Name, StringComparison.Ordinal)) + { + return previous.Id; + } + } + + return Map.Rooms.Count == 0 ? 1 : Math.Max(0, Map.Rooms.Keys.Max()) + 1; + } + + /// + /// The room's exits as the source reported them. A source with destinations (GMCP) is authoritative and + /// replaces the exits wholesale; an exit reported with no destination keeps a link the map already has + /// and is otherwise a stub. + /// + private static void SetExits(MapRoom room, IReadOnlyList exits) + { + var directions = new Dictionary(StringComparer.Ordinal); + var special = new Dictionary(StringComparer.Ordinal); + var stubs = new HashSet(StringComparer.Ordinal); + foreach (var (key, to) in exits) + { + var isDirection = MapDirections.All.Contains(key); + var target = isDirection ? directions : special; + if (to is { } id) + { + target[key] = id; + } + else if ((isDirection ? room.Exits : room.SpecialExits).TryGetValue(key, out var linked)) + { + target[key] = linked; + } + else + { + stubs.Add(key); + } + } + + room.Exits = directions; + room.SpecialExits = special; + room.Stubs = stubs; + } + + /// + /// Records that from leads to , and, + /// when has a stub back the opposite way, that it leads back. A link the server + /// already gave is left alone. + /// + private static void Link(MapRoom from, string walked, MapRoom to) + { + var isDirection = MapDirections.All.Contains(walked); + var exits = isDirection ? from.Exits : from.SpecialExits; + if (!exits.ContainsKey(walked)) + { + exits[walked] = to.Id; + } + + from.Stubs.Remove(walked); + + if (isDirection && MapDirections.Reverse(walked) is { } back && to.Stubs.Remove(back)) + { + to.Exits[back] = from.Id; + } + } + + private void Place(MapRoom room, MapRoom? previous, string? walked) + { + if (previous is null) + { + (room.X, room.Y, room.Z) = (0, 0, 0); + return; + } + + var (dx, dy, dz) = walked is null ? (0, 0, 0) : MapDirections.Offset(walked); + if ((dx, dy, dz) != (0, 0, 0)) + { + var (x, y, z) = (previous.X + dx, previous.Y + dy, previous.Z + dz); + for (var guard = 0; guard < 64 && Map.IsOccupied(room.AreaId, x, y, z, room.Id); guard++) + { + (x, y, z) = (x + dx, y + dy, z + dz); + } + + (room.X, room.Y, room.Z) = (x, y, z); + return; + } + + // No direction to step in: the nearest free cell around the previous room, ring by ring. + for (var ring = 1; ring < 64; ring++) + { + for (var oy = -ring; oy <= ring; oy++) + { + for (var ox = -ring; ox <= ring; ox++) + { + if (Math.Max(Math.Abs(ox), Math.Abs(oy)) != ring) + { + continue; + } + + if (!Map.IsOccupied(room.AreaId, previous.X + ox, previous.Y + oy, previous.Z, room.Id)) + { + (room.X, room.Y, room.Z) = (previous.X + ox, previous.Y + oy, previous.Z); + return; + } + } + } + } + + (room.X, room.Y, room.Z) = (previous.X, previous.Y, previous.Z); + } +} diff --git a/src/SharpMUTerm.Core/Mapping/MapStore.cs b/src/SharpMUTerm.Core/Mapping/MapStore.cs new file mode 100644 index 00000000..d539c590 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/MapStore.cs @@ -0,0 +1,242 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace SharpMUTerm.Core.Mapping; + +/// +/// Where maps live: one JSON file per server under maps/ beside config.json, versioned on its +/// own and written through a temporary file and a rename. +/// +/// Keyed by endpoint, not world — MsspCache's reasoning. A map describes the game a server +/// runs, and a world name is a label the user can rename out from under it; two worlds pointed at one server +/// share one map. +/// +/// +/// Memory-only by construction when built without a directory, which is what every test and snapshot +/// gets: maps are still kept for the life of the process, and nothing is read or written. Only +/// Program hands one a directory. +/// +/// +/// A file from a newer client is never overwritten: its map loads empty, +/// says why, and saves to that endpoint are skipped, so running an older build cannot destroy a map. A file +/// that is not a map at all is renamed with and mapping starts again. A file with +/// no version is the first layout. +/// +/// +public sealed class MapStore +{ + /// The folder beside config.json. + public const string FolderName = "maps"; + + /// What an unreadable map file is renamed with, so a new one can be written. + public const string AsideSuffix = ".unreadable"; + + /// This file's schema version. + public const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + private readonly string? _directory; + private readonly Dictionary _maps = new(StringComparer.Ordinal); + private readonly Dictionary _problems = new(StringComparer.Ordinal); + private readonly HashSet _readOnly = new(StringComparer.Ordinal); + + /// A store; null — the default — is memory-only. + public MapStore(string? directory = null) + { + _directory = string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory); + } + + /// The maps folder beside a configuration file. + public static string PathFor(string configurationPath) + { + ArgumentException.ThrowIfNullOrEmpty(configurationPath); + var directory = Path.GetDirectoryName(Path.GetFullPath(configurationPath)); + return Path.Combine(directory ?? string.Empty, FolderName); + } + + public ILogger Logger { get; set; } = NullLogger.Instance; + + public bool IsPersistent => _directory is not null; + + /// The identity a map is filed under: the host lower-cased without a trailing dot, and the port. + public static string Key(string? host, int port) => + string.Concat( + (host ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(), + ":", + port.ToString(CultureInfo.InvariantCulture)); + + /// + /// The file a key is stored in: every character outside [a-z0-9.-] becomes _, so a host + /// can never name a path outside the folder. + /// + public static string FileNameFor(string key) + { + var name = new StringBuilder(key.Length + 5); + foreach (var ch in key) + { + name.Append(ch is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '.' ? ch : '_'); + } + + // A key of only dots would be "." or ".."; no host is that, but the folder must still hold the file. + return (name.ToString().Trim('.') is { Length: > 0 } safe ? safe : "_") + ".json"; + } + + /// Why this endpoint's file could not be read, or null. + public string? ProblemFor(string? host, int port) => _problems.GetValueOrDefault(Key(host, port)); + + /// The map for this endpoint — read from disk the first time it is asked for, then held. + public WorldMap For(string? host, int port) + { + var key = Key(host, port); + if (_maps.TryGetValue(key, out var held)) + { + return held; + } + + var map = _directory is null ? new WorldMap() : Read(key); + _maps[key] = map; + return map; + } + + /// Writes this endpoint's map, when the store is persistent and the file is ours to replace. + public void Save(string? host, int port) + { + var key = Key(host, port); + if (_directory is null || _readOnly.Contains(key) || !_maps.TryGetValue(key, out var map)) + { + return; + } + + var path = Path.Combine(_directory, FileNameFor(key)); + try + { + Directory.CreateDirectory(_directory); + var temporary = path + ".tmp"; + File.WriteAllText(temporary, Serialize(map, key)); + File.Move(temporary, path, overwrite: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or NotSupportedException) + { + Logger.LogWarning(e, "The map for {Endpoint} could not be written to {Path}.", key, path); + } + } + + /// The file's text for a map. + public static string Serialize(WorldMap map, string key) + { + ArgumentNullException.ThrowIfNull(map); + var document = new JsonObject + { + ["version"] = CurrentVersion, + ["endpoint"] = key, + ["areas"] = JsonSerializer.SerializeToNode(map.Areas.Values.OrderBy(a => a.Id).ToArray(), Json), + ["rooms"] = JsonSerializer.SerializeToNode(map.Rooms.Values.OrderBy(r => r.Id).ToArray(), Json), + }; + return document.ToJsonString(Json); + } + + /// + /// A map from a file's text. Throws for a version newer than this + /// client's, and for text that is not a map. + /// + public static WorldMap Deserialize(string text) + { + var document = JsonNode.Parse(text) as JsonObject ?? throw new JsonException("A map file is a JSON object."); + var version = document["version"]?.GetValue() ?? 1; + if (version > CurrentVersion) + { + throw new NotSupportedException( + $"this map was written by a newer SharpMUTerm (map version {version}; this one reads {CurrentVersion})"); + } + + var map = new WorldMap(); + foreach (var area in document["areas"]?.Deserialize(Json) ?? []) + { + map.Areas[area.Id] = area; + } + + foreach (var room in document["rooms"]?.Deserialize(Json) ?? []) + { + map.Add(Normalised(room)); + } + + return map; + } + + /// + /// Puts back what a hand-edited file may have lost: the dictionaries' ordinal comparers (the serializer + /// builds default ones), a weight of at least 1, and only real directions among the exits. + /// + private static MapRoom Normalised(MapRoom room) + { + var exits = new Dictionary(StringComparer.Ordinal); + foreach (var (direction, to) in room.Exits ?? []) + { + if (MapDirections.Normalise(direction) is { } name) + { + exits[name] = to; + } + } + + room.Exits = exits; + room.SpecialExits = new Dictionary(room.SpecialExits ?? [], StringComparer.Ordinal); + room.ExitWeights = new Dictionary(room.ExitWeights ?? [], StringComparer.Ordinal); + room.LockedExits = new HashSet(room.LockedExits ?? [], StringComparer.Ordinal); + room.Stubs = new HashSet(room.Stubs ?? [], StringComparer.Ordinal); + room.UserData = new Dictionary(room.UserData ?? [], StringComparer.Ordinal); + room.Name ??= string.Empty; + room.Weight = Math.Max(1, room.Weight); + return room; + } + + private WorldMap Read(string key) + { + var path = Path.Combine(_directory!, FileNameFor(key)); + if (!File.Exists(path)) + { + return new WorldMap(); + } + + try + { + return Deserialize(File.ReadAllText(path)); + } + catch (NotSupportedException e) + { + _readOnly.Add(key); + _problems[key] = e.Message; + } + catch (Exception e) when (e is JsonException or InvalidOperationException or FormatException) + { + // Unreadable, not newer: moved aside rather than overwritten, so mapping can start again and + // whatever the file held is still there for someone who wants it back. + _problems[key] = $"the map file could not be read ({e.Message}); it was moved to {Path.GetFileName(path)}{AsideSuffix}"; + try + { + File.Move(path, path + AsideSuffix, overwrite: true); + } + catch (Exception move) when (move is IOException or UnauthorizedAccessException) + { + _readOnly.Add(key); + _problems[key] = $"the map file could not be read ({e.Message}) or moved aside; it is left as it is"; + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _readOnly.Add(key); + _problems[key] = $"the map file could not be read ({e.Message}); it is left as it is"; + } + + Logger.LogWarning("The map for {Endpoint} was not loaded: {Problem}", key, _problems[key]); + return new WorldMap(); + } +} diff --git a/src/SharpMUTerm.Core/Mapping/MxpRoomSource.cs b/src/SharpMUTerm.Core/Mapping/MxpRoomSource.cs new file mode 100644 index 00000000..6034489e --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/MxpRoomSource.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Mapping; + +/// +/// Rooms from MXP: the text of the elements a server flags RoomNum, RoomName and RoomExit +/// (or the spec's line tags 10–12, which flag the same). A name, then its exits, complete a room; a number seen +/// before them is its id. RoomDesc is not needed to place a room and is not read. +/// +public sealed class MxpRoomSource : IRoomSource +{ + private static readonly Regex ExitSeparator = new(@"\s*(?:,|\band\b|\s)\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50)); + + private readonly WorldSession _session; + private string? _name; + private long? _id; + + public MxpRoomSource(WorldSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _session.MxpFlagged += OnFlagged; + } + + public event EventHandler? RoomEntered; + + public void Dispose() => _session.MxpFlagged -= OnFlagged; + + private void OnFlagged(object? sender, MxpFlagText e) => Observe(e.Flag, e.Text); + + /// One flagged element's text. Public so the reading can be driven without a session. + public void Observe(string flag, string text) + { + switch (flag.Trim().ToLowerInvariant()) + { + case "roomnum": + _id = long.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) ? id : null; + break; + case "roomname": + _name = text.Trim(); + break; + case "roomexit": + if (_name is null) + { + return; + } + + var list = text.Trim(); + var colon = list.IndexOf(':', StringComparison.Ordinal); + if (colon >= 0 && colon < 12) + { + list = list[(colon + 1)..]; + } + + var exits = ExitSeparator.Split(list) + .Select(x => x.Trim().Trim('.', '[', ']', '(', ')')) + .Where(x => x.Length > 0 && !x.Equals("none", StringComparison.OrdinalIgnoreCase)) + .Select(x => new ObservedExit(MapDirections.Normalise(x) ?? x.ToLowerInvariant(), null)) + .ToList(); + RoomEntered?.Invoke(this, new RoomObservation(_id, _name, exits)); + _name = null; + _id = null; + break; + } + } +} diff --git a/src/SharpMUTerm.Core/Mapping/PathFinder.cs b/src/SharpMUTerm.Core/Mapping/PathFinder.cs new file mode 100644 index 00000000..5c7810f1 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/PathFinder.cs @@ -0,0 +1,89 @@ +namespace SharpMUTerm.Core.Mapping; + +/// One step of a walk: the command to send, and the room it should reach. +public readonly record struct PathStep(string Command, long To); + +/// +/// The cheapest walk between two rooms, as commands. Each step costs its exit's weight when it has one and +/// otherwise the room it enters; locked rooms and locked exits are never taken, and an exit to a room the map +/// does not hold is not a way anywhere. +/// +/// Dijkstra, not A* with a distance heuristic. A heuristic has to be a lower bound to stay correct, and +/// a map's coordinates are not one: special exits and portals jump across the grid, placement nudges rooms +/// off their true step, and areas share coordinates. A zero heuristic is always admissible, and a 10,000-room +/// map is still a few milliseconds. +/// +/// +public static class PathFinder +{ + /// + /// The steps from to : empty when they are the same room, null + /// when there is no way. + /// + public static IReadOnlyList? Find(WorldMap map, long from, long to) + { + ArgumentNullException.ThrowIfNull(map); + if (map.Room(from) is null || map.Room(to) is not { Locked: false }) + { + return null; + } + + if (from == to) + { + return []; + } + + var cost = new Dictionary { [from] = 0 }; + var previous = new Dictionary(); + var queue = new PriorityQueue(); + queue.Enqueue(from, 0); + + while (queue.TryDequeue(out var current, out var reached)) + { + if (current == to) + { + return Walk(previous, from, to); + } + + if (reached > cost[current] || map.Room(current) is not { } room) + { + continue; // a stale entry, superseded by a cheaper one + } + + foreach (var (command, key, next) in room.AllExits()) + { + if (room.LockedExits.Contains(key) || map.Room(next) is not { Locked: false } target) + { + continue; + } + + var step = room.ExitWeights.TryGetValue(key, out var weight) && weight > 0 + ? weight + : Math.Max(1, target.Weight); + var total = reached + step; + if (cost.TryGetValue(next, out var known) && known <= total) + { + continue; + } + + cost[next] = total; + previous[next] = (current, command); + queue.Enqueue(next, total); + } + } + + return null; + } + + private static List Walk(Dictionary previous, long from, long to) + { + var steps = new List(); + for (var at = to; at != from; at = previous[at].Room) + { + steps.Add(new PathStep(previous[at].Command, at)); + } + + steps.Reverse(); + return steps; + } +} diff --git a/src/SharpMUTerm.Core/Mapping/RoomObservation.cs b/src/SharpMUTerm.Core/Mapping/RoomObservation.cs new file mode 100644 index 00000000..35d1d688 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/RoomObservation.cs @@ -0,0 +1,31 @@ +namespace SharpMUTerm.Core.Mapping; + +/// One exit as a source saw it: a direction's long name or a command, and the room it leads to when known. +public readonly record struct ObservedExit(string Key, long? To); + +/// +/// "The character is in this room, and these are its exits", as a reports it. +/// is the game's own number, or null for a source that has none (the text source), in which +/// case the works out which room it is. +/// +public sealed record RoomObservation(long? Id, string Name, IReadOnlyList Exits) +{ + public string? Area { get; init; } + + public string? Environment { get; init; } + + /// Coordinates the server gave, when it gave any; otherwise the recorder places the room. + public (int X, int Y, int Z)? Coordinates { get; init; } + + /// + /// True when this only updates the exits of the room the character is already in (MUME's + /// Room.UpdateExits) rather than reporting a move. + /// + public bool ExitsOnly { get; init; } +} + +/// Something that can tell where the character is. +public interface IRoomSource : IDisposable +{ + event EventHandler? RoomEntered; +} diff --git a/src/SharpMUTerm.Core/Mapping/SessionMap.cs b/src/SharpMUTerm.Core/Mapping/SessionMap.cs new file mode 100644 index 00000000..c3eaa001 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/SessionMap.cs @@ -0,0 +1,165 @@ +namespace SharpMUTerm.Core.Mapping; + +/// +/// One connection's map and the walk across it: what the map window draws, what /walk and a click on a +/// room start, and what a script's map.* reads. Held by the session () +/// so the scripting bridge can reach it without knowing about the client. +/// +/// The map is changed on the client's UI thread. is how anything else asks for a change, and +/// every read from elsewhere takes . +/// +/// +public sealed class SessionMap : IDisposable +{ + public SessionMap(MapRecorder recorder, Speedwalk walk) + { + Recorder = recorder ?? throw new ArgumentNullException(nameof(recorder)); + Walk = walk ?? throw new ArgumentNullException(nameof(walk)); + } + + public MapRecorder Recorder { get; } + + public Speedwalk Walk { get; } + + public WorldMap Map => Recorder.Map; + + /// Where a change asked for from another thread is run. Null runs it in place. + public Action? Post { get; init; } + + /// The room the character is in: id, name and area name, or null before the first room. + public (long Id, string Name, string? Area)? Current + { + get + { + lock (Recorder.SyncRoot) + { + if (Recorder.CurrentRoom is not { } room) + { + return null; + } + + var area = room.AreaId is { } a && Map.Areas.TryGetValue(a, out var found) ? found.Name : null; + return (room.Id, room.Name, area); + } + } + } + + /// + /// The room names: a room number, or else a room name, ignoring case. Among rooms + /// sharing a name the nearest by path wins, then the lowest number, so /walk Town Square on a map with + /// two of them goes to the one you can reach soonest. + /// + public MapRoom? Find(string target) + { + var text = target.Trim(); + if (text.Length == 0) + { + return null; + } + + lock (Recorder.SyncRoot) + { + if (long.TryParse(text, out var id) && Map.Room(id) is { } byId) + { + return byId; + } + + var named = Map.Rooms.Values + .Where(r => string.Equals(r.Name, text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.Id) + .ToList(); + if (named.Count <= 1 || Recorder.CurrentRoomId is not { } here) + { + return named.FirstOrDefault(); + } + + return named + .Select(r => (Room: r, Steps: PathFinder.Find(Map, here, r.Id)?.Count ?? int.MaxValue)) + .OrderBy(p => p.Steps) + .ThenBy(p => p.Room.Id) + .First().Room; + } + } + + /// The way from the current room to , or null when there is none. + public IReadOnlyList? PathTo(long to) + { + lock (Recorder.SyncRoot) + { + return Recorder.CurrentRoomId is { } here ? PathFinder.Find(Map, here, to) : null; + } + } + + /// + /// Starts walking to (a number or a name). Returns null when the walk started, + /// otherwise what stopped it, for the player. + /// + public string? WalkTo(string target) + { + if (Find(target) is not { } room) + { + return $"no room on the map is called {Snippet(target)}"; + } + + return WalkTo(room.Id); + } + + /// + public string? WalkTo(long to) + { + if (Recorder.CurrentRoomId is null) + { + return "the map does not know where you are yet"; + } + + if (PathTo(to) is not { } path) + { + return "the map knows no way there"; + } + + Run(() => Walk.Start(path)); + return null; + } + + /// Locks or unlocks a room, so a path never goes through it. + public bool SetLocked(long id, bool locked) + { + if (Map.Room(id) is null) + { + return false; + } + + Run(() => Recorder.Edit(map => + { + if (map.Room(id) is { } room) + { + room.Locked = locked; + } + })); + return true; + } + + public void Dispose() + { + Walk.Dispose(); + Recorder.Dispose(); + } + + private void Run(Action action) + { + if (Post is { } post) + { + post(action); + } + else + { + action(); + } + } + + private static string Snippet(string text) + { + var trimmed = text.Trim(); + return trimmed.Length <= 40 ? $"'{trimmed}'" : $"'{trimmed[..39]}…'"; + } +} diff --git a/src/SharpMUTerm.Core/Mapping/Speedwalk.cs b/src/SharpMUTerm.Core/Mapping/Speedwalk.cs new file mode 100644 index 00000000..5cfbc5da --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/Speedwalk.cs @@ -0,0 +1,191 @@ +using System.Text.RegularExpressions; + +namespace SharpMUTerm.Core.Mapping; + +/// +/// Walks a path one step at a time: a step is sent, and the next goes when the map says the character has +/// arrived where that step leads — the generic mapper's speedwalk_wait. Sending them all at once would +/// leave a game that drops a step (a closed door, a fight) walking the rest of the path from the wrong room. +/// +/// It stops on its own when the walk is done; when the character arrives somewhere off the path; when a line +/// matches the world's failure pattern (); and when a +/// step has not arrived within , which is what a game that reports no rooms at all +/// would otherwise leave hanging for ever. +/// +/// +public sealed class Speedwalk : IDisposable +{ + private static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(50); + + private readonly MapRecorder _recorder; + private readonly Func _send; + private readonly TimeProvider _time; + private readonly Regex? _failure; + private readonly List _steps = new(); + private ITimer? _timer; + private int _next; + private long? _from; + private long _version; + + public Speedwalk(MapRecorder recorder, Func send, string? failurePattern = null, TimeProvider? time = null) + { + _recorder = recorder ?? throw new ArgumentNullException(nameof(recorder)); + _send = send ?? throw new ArgumentNullException(nameof(send)); + _time = time ?? TimeProvider.System; + if (!string.IsNullOrWhiteSpace(failurePattern)) + { + try + { + _failure = new Regex(failurePattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, MatchTimeout); + } + catch (ArgumentException) + { + _failure = null; // a bad pattern only costs the early stop; the timeout still ends a stuck walk + } + } + + _recorder.Changed += OnMapChanged; + } + + /// How long a step may take to arrive before the walk gives up. + public static readonly TimeSpan StepTimeout = TimeSpan.FromSeconds(8); + + /// Where timer callbacks run; the client sets this to its UI thread. Null runs them in place. + public Action? Post { get; init; } + + public bool IsWalking => _next < _steps.Count; + + /// The room the walk is going to, while it walks. + public long? Destination => IsWalking ? _steps[^1].To : null; + + /// How many steps have not yet been sent or arrived, while walking. + public int Remaining => Math.Max(0, _steps.Count - _next); + + /// The rooms still ahead, in order — what the map window highlights. + public IEnumerable RoomsAhead => _steps.Skip(_next).Select(s => s.To); + + /// Raised when the walk starts, takes a step, or stops; the text says why it stopped, or is null. + public event EventHandler? Changed; + + /// Walks , replacing any walk already going. An empty path is already there. + public void Start(IReadOnlyList steps) + { + ArgumentNullException.ThrowIfNull(steps); + _steps.Clear(); + _steps.AddRange(steps); + _next = 0; + _from = _recorder.CurrentRoomId; + if (_steps.Count == 0) + { + Changed?.Invoke(this, "already there"); + return; + } + + SendNext(); + } + + /// Stops walking; the reason is shown to the player. + public void Stop(string reason) + { + if (!IsWalking) + { + return; + } + + _steps.Clear(); + _next = 0; + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + Changed?.Invoke(this, reason); + } + + /// A line of output, checked against the failure pattern while walking. + public void Observe(string line) + { + if (!IsWalking || _failure is null) + { + return; + } + + try + { + if (_failure.IsMatch(line)) + { + Stop($"stopped: {line.Trim()}"); + } + } + catch (RegexMatchTimeoutException) + { + } + } + + public void Dispose() + { + _recorder.Changed -= OnMapChanged; + _timer?.Dispose(); + } + + private void SendNext() + { + var step = _steps[_next]; + _version++; + _timer ??= _time.CreateTimer(_ => OnTimeout(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer.Change(StepTimeout, Timeout.InfiniteTimeSpan); + _ = _send(step.Command); + Changed?.Invoke(this, null); + } + + private void OnTimeout() + { + var version = _version; + void Check() + { + if (IsWalking && version == _version) + { + Stop("stopped: the game did not say where that step went"); + } + } + + if (Post is { } post) + { + post(Check); + } + else + { + Check(); + } + } + + private void OnMapChanged(object? sender, EventArgs e) + { + if (!IsWalking || _recorder.CurrentRoomId is not { } here) + { + return; + } + + var expected = _steps[_next].To; + if (here == expected) + { + _from = expected; + _next++; + if (IsWalking) + { + SendNext(); + } + else + { + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _steps.Clear(); + _next = 0; + Changed?.Invoke(this, "arrived"); + } + + return; + } + + // An exits update or a look leaves the character where it was; anywhere else is off the path. + if (here != _from) + { + Stop("stopped: that step went somewhere else"); + } + } +} diff --git a/src/SharpMUTerm.Core/Mapping/TextRoomSource.cs b/src/SharpMUTerm.Core/Mapping/TextRoomSource.cs new file mode 100644 index 00000000..05fe52b3 --- /dev/null +++ b/src/SharpMUTerm.Core/Mapping/TextRoomSource.cs @@ -0,0 +1,102 @@ +using System.Text.RegularExpressions; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Mapping; + +/// +/// Rooms from the text a game prints, for games that send no room data: a line matching the world's +/// names a room, and the next line matching its +/// lists the exits and completes it. Text has no room numbers, so the +/// observation carries none and the works out which room it is. +/// +/// The patterns are read when the source is built; a bad one switches the source off rather than throwing, +/// and says which. Matching has a timeout, because it runs on every line. +/// +/// +public sealed class TextRoomSource : IRoomSource +{ + private static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(50); + private static readonly Regex ExitSeparator = new(@"\s*(?:,|\band\b|\s)\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, MatchTimeout); + + private readonly WorldSession _session; + private readonly Regex? _name; + private readonly Regex? _exits; + private string? _pendingName; + + public TextRoomSource(WorldSession session, MapSettings settings) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + ArgumentNullException.ThrowIfNull(settings); + _name = Compile(settings.RoomNamePattern, "room name"); + _exits = Compile(settings.ExitsPattern, "exits"); + if (_exits is not null) + { + _session.LinePrinted += OnLine; + } + } + + public event EventHandler? RoomEntered; + + /// Why a pattern was not used, or null. + public string? Problem { get; private set; } + + /// Whether this source is listening at all: it needs at least the exits pattern. + public bool IsActive => _exits is not null; + + public void Dispose() => _session.LinePrinted -= OnLine; + + private Regex? Compile(string? pattern, string what) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return null; + } + + try + { + return new Regex(pattern, RegexOptions.CultureInvariant, MatchTimeout); + } + catch (ArgumentException e) + { + Problem = $"the map's {what} pattern is not a regular expression: {e.Message}"; + return null; + } + } + + private void OnLine(object? sender, StyledLine line) => Observe(line.Text); + + /// One line of output. Public so the matching can be driven without a session's plumbing. + public void Observe(string text) + { + try + { + if (_name?.Match(text) is { Success: true } named) + { + _pendingName = (named.Groups["name"] is { Success: true } group ? group.Value : named.Value).Trim(); + return; + } + + if (_exits?.Match(text) is not { Success: true } listed) + { + return; + } + + var list = listed.Groups["exits"] is { Success: true } exits ? exits.Value : listed.Value; + var observed = ExitSeparator.Split(list) + .Select(e => e.Trim().Trim('.', '[', ']', '(', ')')) + .Where(e => e.Length > 0 && !e.Equals("none", StringComparison.OrdinalIgnoreCase)) + .Select(e => new ObservedExit(MapDirections.Normalise(e) ?? e.ToLowerInvariant(), null)) + .ToList(); + + var name = _pendingName ?? string.Empty; + _pendingName = null; + RoomEntered?.Invoke(this, new RoomObservation(null, name, observed)); + } + catch (RegexMatchTimeoutException) + { + // A pathological line against a user's pattern: skipped, never the session's problem. + } + } +} diff --git a/src/SharpMUTerm.Core/Media/IAudioOutput.cs b/src/SharpMUTerm.Core/Media/IAudioOutput.cs new file mode 100644 index 00000000..3f361b2d --- /dev/null +++ b/src/SharpMUTerm.Core/Media/IAudioOutput.cs @@ -0,0 +1,32 @@ +namespace SharpMUTerm.Core.Media; + +/// +/// The sound card, as the media queue needs it: start a file, and control what was started. Implemented by a +/// host (a desktop process has one; a snapshot and a test have none, or a recording fake), so everything above +/// it — the rules, the cache, the captions — is plain Core and is tested without a device. +/// +public interface IAudioOutput : IDisposable +{ + /// Starts , or returns null when it cannot be played (unreadable, unsupported). + IAudioVoice? Play(string path, AudioStart start); +} + +/// How a file starts. Volume is final, 0 to 1: the queue has already applied the player's own levels. +public sealed record AudioStart(float Volume, int Loops, TimeSpan Start, TimeSpan? Finish, TimeSpan FadeIn, TimeSpan FadeOut); + +/// One file playing. +public interface IAudioVoice : IDisposable +{ + /// Sets the volume at once, 0 to 1. + void SetVolume(float volume); + + /// Moves the volume to over . + void FadeTo(float volume, TimeSpan over); + + void Pause(); + + void Resume(); + + /// Raised once, when the file has played its loops or been stopped. May be raised on any thread. + event EventHandler? Ended; +} diff --git a/src/SharpMUTerm.Core/Media/MediaCache.cs b/src/SharpMUTerm.Core/Media/MediaCache.cs new file mode 100644 index 00000000..87b1b383 --- /dev/null +++ b/src/SharpMUTerm.Core/Media/MediaCache.cs @@ -0,0 +1,225 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SharpMUTerm.Core.Media; + +/// +/// Where a world's sound files are kept: fetched once from the URL the server names, then read from disk. +/// +/// What may be fetched is narrow on purpose, because the name and the URL are server text: http(s) +/// only (WebImageLoader's rule); a name that is relative, has no .. segment, no drive and no +/// backslash; one of four audio extensions; and a size cap per file and per world, enforced while reading, so +/// a server cannot fill the disk. A file is cached by a hash of its full URL, so a name never becomes a path. +/// +/// +/// A cache with no root is memory-only by construction and fetches nothing at all, which is what a snapshot +/// and a test get: the mssp: rule. +/// +/// +public sealed class MediaCache +{ + /// The audio types this client fetches and plays. + public static readonly IReadOnlyList Extensions = ["wav", "mp3", "ogg", "flac"]; + + private readonly string? _root; + private readonly Func> _fetch; + + public MediaCache(string? root = null, Func>? fetch = null) + { + _root = string.IsNullOrWhiteSpace(root) ? null : root; + _fetch = fetch ?? FetchAsync; + } + + /// The largest file fetched. + public long MaxFileBytes { get; init; } = 32L * 1024 * 1024; + + /// The most one world's cache holds; the oldest files go first past it. + public long MaxWorldBytes { get; init; } = 512L * 1024 * 1024; + + /// + /// Folders searched by name before anything is fetched — a soundpack the player installed. Their files are + /// the player's, so they are read as they are and never fetched into. + /// + public IList LocalFolders { get; } = new List(); + + /// Whether this cache can hold anything at all. + public bool HasRoot => _root is not null; + + /// + /// The platform's cache folder, beside the scrollback spill's (~/.cache/SharpMUTerm/media on Linux). + /// A cache and not configuration: a world's files can be fetched again, and are never worth backing up. + /// + public static string DefaultRoot => + Path.Combine(Path.GetDirectoryName(Text.FileScrollbackSpill.DefaultRoot) ?? ".", "media"); + + /// Why a server's file name is refused, or null when it is acceptable. + public static string? CheckName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 200) + { + return "no file name"; + } + + if (name.Contains('\\') || name.Contains(':') || name.StartsWith('/') || Path.IsPathRooted(name) + || name.Split('/').Any(segment => segment is ".." or "." || segment.Length == 0) + || name.Any(char.IsControl)) + { + return "not a relative file name"; + } + + var extension = Path.GetExtension(name).TrimStart('.').ToLowerInvariant(); + return Extensions.Contains(extension) ? null : $"not a sound file (.{extension})"; + } + + /// The full URL of under the folder , or null. + public static Uri? UrlFor(string name, string? folder) + { + if (string.IsNullOrWhiteSpace(folder) + || !Uri.TryCreate(folder.TrimEnd('/') + "/", UriKind.Absolute, out var root) + || root.Scheme is not ("http" or "https")) + { + return null; + } + + var path = string.Join('/', name.Split('/').Select(Uri.EscapeDataString)); + return Uri.TryCreate(root, path, out var full) && full.Scheme is "http" or "https" ? full : null; + } + + /// + /// The local path of for : a soundpack's copy, else the cached + /// one, else fetched from when allows. Null when there is + /// no file to play; says why, for the player. + /// + public async Task<(string? Path, string? Problem)> ResolveAsync( + string world, string name, string? folder, bool download = true, CancellationToken cancellationToken = default) + { + if (CheckName(name) is { } refused) + { + return (null, refused); + } + + foreach (var local in LocalFolders) + { + var candidate = Path.GetFullPath(Path.Combine(local, name)); + if (candidate.StartsWith(Path.GetFullPath(local), StringComparison.Ordinal) && File.Exists(candidate)) + { + return (candidate, null); + } + } + + if (_root is null) + { + return (null, "sounds are not kept here"); + } + + if (UrlFor(name, folder) is not { } url) + { + return (null, folder is null ? "the game named no place to fetch it from" : "only http and https are fetched"); + } + + var directory = Path.Combine(_root, Sanitise(world)); + var path = Path.Combine(directory, Hash(url.AbsoluteUri) + Path.GetExtension(name).ToLowerInvariant()); + if (File.Exists(path)) + { + File.SetLastWriteTimeUtc(path, DateTime.UtcNow); + return (path, null); + } + + if (!download) + { + return (null, "downloading is off for this world"); + } + + byte[]? bytes; + try + { + bytes = await _fetch(url, MaxFileBytes, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException or InvalidOperationException) + { + return (null, $"could not fetch it: {ex.Message}"); + } + + if (bytes is null || bytes.Length == 0) + { + return (null, "could not fetch it"); + } + + if (bytes.Length > MaxFileBytes) + { + return (null, "the file is too large"); + } + + try + { + Directory.CreateDirectory(directory); + MakeRoom(directory, bytes.Length); + var temporary = path + ".part"; + await File.WriteAllBytesAsync(temporary, bytes, cancellationToken).ConfigureAwait(false); + File.Move(temporary, path, overwrite: true); + return (path, null); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return (null, $"could not keep it: {ex.Message}"); + } + } + + private void MakeRoom(string directory, long incoming) + { + var files = new DirectoryInfo(directory).GetFiles().OrderBy(f => f.LastWriteTimeUtc).ToList(); + var total = files.Sum(f => f.Length); + foreach (var file in files) + { + if (total + incoming <= MaxWorldBytes) + { + break; + } + + total -= file.Length; + file.Delete(); + } + } + + private static string Sanitise(string world) + { + var builder = new StringBuilder(); + foreach (var c in world) + { + builder.Append(char.IsLetterOrDigit(c) || c is '.' or '-' ? c : '_'); + } + + var text = builder.ToString().Trim('.'); + return text.Length == 0 ? "_" : text; + } + + private static string Hash(string text) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..32]; + + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) }; + + /// Reads at most + 1 bytes, so an oversized file is refused without being held. + private static async Task FetchAsync(Uri url, long max, CancellationToken cancellationToken) + { + using var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > max) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0) + { + buffer.Write(chunk, 0, read); + if (buffer.Length > max) + { + return null; + } + } + + return buffer.ToArray(); + } +} diff --git a/src/SharpMUTerm.Core/Media/MediaCommand.cs b/src/SharpMUTerm.Core/Media/MediaCommand.cs new file mode 100644 index 00000000..b16625b7 --- /dev/null +++ b/src/SharpMUTerm.Core/Media/MediaCommand.cs @@ -0,0 +1,169 @@ +using TelnetNegotiationCore.Gmcp; + +namespace SharpMUTerm.Core.Media; + +/// Whether a request is a sound effect or music. MCMP's third kind, video, is not played. +public enum MediaKind +{ + Sound, + Music, +} + +/// +/// One thing to play, as MCMP's Client.Media.Play describes it, with the specification's defaults filled +/// in. MSP's !!SOUND and !!MUSIC become one of these too, so one queue applies one set of rules. +/// +public sealed record MediaRequest(string Name) +{ + /// The folder the file is fetched from, when the server's default does not apply. + public string? Url { get; init; } + + public MediaKind Kind { get; init; } = MediaKind.Sound; + + public string? Tag { get; init; } + + /// 1 to 100, relative to the player's own volume; the specification's default is 50. + public int Volume { get; init; } = 50; + + public TimeSpan FadeIn { get; init; } + + public TimeSpan FadeOut { get; init; } + + public TimeSpan Start { get; init; } + + public TimeSpan? Finish { get; init; } + + /// How many times it plays; −1 loops until stopped. + public int Loops { get; init; } = 1; + + /// 1 to 100, or null for none. While it plays, media of lower priority is halted and refused. + public int? Priority { get; init; } + + /// Music only: true leaves the same file playing when it is asked for again, false restarts it. + public bool Continue { get; init; } = true; + + /// A slot: a different file played under the same key replaces the one before. + public string? Key { get; init; } + + public string? Caption { get; init; } + + /// + /// MSP's rule for sounds: one at a time, and a new one replaces the one playing only at a higher priority. + /// MCMP plays sounds alongside each other. + /// + public bool Exclusive { get; init; } + + /// The words a caption line shows: the server's caption, else the file's name without its folder or extension. + public string CaptionText => + !string.IsNullOrWhiteSpace(Caption) ? Caption.Trim() : Path.GetFileNameWithoutExtension(Name.Replace('\\', '/').Split('/')[^1]); +} + +/// +/// Which playing media a stop or a pause applies to: every field that is set has to match, and a filter with +/// nothing set matches everything ("an empty body will stop all media"). +/// +public sealed record MediaFilter +{ + public string? Name { get; init; } + + public MediaKind? Kind { get; init; } + + public string? Tag { get; init; } + + /// Matches media of this priority or lower. + public int? Priority { get; init; } + + public string? Key { get; init; } + + public static MediaFilter All { get; } = new(); + + public bool Matches(MediaRequest media) => + (Name is null || string.Equals(Name, media.Name, StringComparison.Ordinal)) + && (Kind is null || Kind == media.Kind) + && (Tag is null || string.Equals(Tag, media.Tag, StringComparison.OrdinalIgnoreCase)) + && (Priority is null || (media.Priority ?? 0) <= Priority) + && (Key is null || string.Equals(Key, media.Key, StringComparison.Ordinal)); +} + +/// What a server asked the media queue to do. +public abstract record MediaCommand +{ + /// The module a client lists in Core.Supports.Set to say it plays MCMP media. + public const string GmcpModule = "Client.Media 1"; + + /// + /// The command a Client.Media.* GMCP message carries, or null for anything else — another package, + /// a body that does not parse, or a video, which this client does not play. + /// + public static MediaCommand? FromGmcp(string package, string json) + { + switch (package) + { + case GmcpPackages.ClientMediaDefault or GmcpPackages.ClientMediaObsoleteDefault: + return MediaDefault.TryParse(json, out var d) ? new SetDefault(d.Url) : null; + case GmcpPackages.ClientMediaLoad: + return TelnetNegotiationCore.Gmcp.MediaLoad.TryParse(json, out var l) ? new Load(l.Name, l.Url) : null; + case GmcpPackages.ClientMediaPlay: + if (!MediaPlay.TryParse(json, out var p) || p.Type == MediaType.Video) + { + return null; + } + + return new Play(new MediaRequest(p.Name) + { + Url = p.Url, + Kind = p.Type == MediaType.Music ? MediaKind.Music : MediaKind.Sound, + Tag = p.Tag, + Volume = Math.Clamp(p.Volume ?? 50, 1, 100), + FadeIn = Milliseconds(p.FadeIn), + FadeOut = Milliseconds(p.FadeOut), + Start = Milliseconds(p.Start), + Finish = p.Finish is > 0 ? Milliseconds(p.Finish) : null, + Loops = p.Loops is -1 or > 0 ? p.Loops.Value : 1, + Priority = p.Priority is { } priority ? Math.Clamp(priority, 1, 100) : null, + Continue = p.Continue ?? true, + Key = p.Key, + Caption = p.Caption, + }); + case GmcpPackages.ClientMediaStop: + if (!MediaStop.TryParse(json, out var s) || s.Type == MediaType.Video) + { + return null; + } + + return new Stop(ToFilter(s.Name, s.Type, s.Tag, s.Priority, s.Key), s.FadeAway == true, s.FadeOut is > 0 ? Milliseconds(s.FadeOut) : null); + case GmcpPackages.ClientMediaPause: + return MediaPause.TryParse(json, out var pause) && pause.Type != MediaType.Video + ? new Pause(ToFilter(pause.Name, pause.Type, pause.Tag, pause.Priority, pause.Key)) + : null; + default: + return null; + } + } + + private static MediaFilter ToFilter(string? name, MediaType? type, string? tag, int? priority, string? key) => new() + { + Name = name, + Kind = type switch { MediaType.Music => MediaKind.Music, MediaType.Sound => MediaKind.Sound, _ => null }, + Tag = tag, + Priority = priority, + Key = key, + }; + + private static TimeSpan Milliseconds(int? value) => value is > 0 ? TimeSpan.FromMilliseconds(value.Value) : TimeSpan.Zero; + + /// Play something. + public sealed record Play(MediaRequest Request) : MediaCommand; + + /// Stop what matches; fades it out first. + public sealed record Stop(MediaFilter Filter, bool FadeAway = false, TimeSpan? FadeOut = null) : MediaCommand; + + /// Pause what matches; a later matching play resumes it. + public sealed record Pause(MediaFilter Filter) : MediaCommand; + + /// Fetch a file now, so it is ready when played. + public sealed record Load(string Name, string? Url) : MediaCommand; + + /// The folder files are fetched from when a request names none. + public sealed record SetDefault(string Url) : MediaCommand; +} diff --git a/src/SharpMUTerm.Core/Media/MediaQueue.cs b/src/SharpMUTerm.Core/Media/MediaQueue.cs new file mode 100644 index 00000000..057d8f69 --- /dev/null +++ b/src/SharpMUTerm.Core/Media/MediaQueue.cs @@ -0,0 +1,294 @@ +namespace SharpMUTerm.Core.Media; + +/// The player's own levels, 0 to 100 each; the effective volume of a file is their product with its own. +public sealed record MediaMix(int Master = 100, int Sound = 100, int Music = 100, bool Muted = false) +{ + public float For(MediaRequest request) + { + if (Muted) + { + return 0f; + } + + var kind = request.Kind == MediaKind.Music ? Music : Sound; + return Math.Clamp(Master, 0, 100) / 100f * Math.Clamp(kind, 0, 100) / 100f * Math.Clamp(request.Volume, 1, 100) / 100f; + } +} + +/// +/// One connection's sounds and music: applies MCMP's rules to what the server asks for and drives an +/// . +/// +/// Priority — while media with a priority plays, anything of lower priority is halted and refused; +/// equal priorities play together. An sound (MSP's) replaces the sound +/// playing only at a higher priority, so a tie keeps what is playing. +/// Key — a different file under the same key replaces the one before. +/// Continue — music already playing is left alone when asked for again, unless continue is false. +/// Pause — a later play that matches paused media resumes it rather than starting it again. +/// +/// Every method is called on one thread (the client's UI thread); a fetch completes through . +/// +public sealed class MediaQueue : IDisposable +{ + private readonly IAudioOutput _output; + private readonly MediaCache _cache; + private readonly string _world; + private readonly TimeProvider _time; + private readonly List _playing = new(); + + /// Requests waiting on a fetch, by number; a stop that matches one takes it out, so it never starts. + private readonly Dictionary _pending = new(); + private readonly CancellationTokenSource _closing = new(); + private string? _defaultUrl; + private long _generation; + + public MediaQueue(IAudioOutput output, MediaCache cache, string world, TimeProvider? time = null) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _world = world; + _time = time ?? TimeProvider.System; + } + + /// The player's levels. Setting it re-levels everything playing. + public MediaMix Mix + { + get => _mix; + set + { + _mix = value ?? new MediaMix(); + foreach (var playing in _playing) + { + playing.Voice.SetVolume(_mix.For(playing.Request)); + } + } + } + + private MediaMix _mix = new(); + + /// Whether files may be fetched for this world. + public bool Download { get; set; } = true; + + /// Where a fetch's completion is run; the client sets this to its UI thread. Null runs it in place. + public Action? Post { get; init; } + + /// Raised when something starts playing, with the request, so its caption can be shown. + public event EventHandler? Started; + + /// Raised when a request cannot be played, with why. + public event EventHandler<(MediaRequest Request, string Problem)>? Failed; + + /// What is playing or paused now, in the order it started. + public IReadOnlyList Current => _playing.Select(p => p.Request).ToArray(); + + public void Apply(MediaCommand command) + { + switch (command) + { + case MediaCommand.SetDefault d: + _defaultUrl = d.Url; + break; + case MediaCommand.Load l: + _ = _cache.ResolveAsync(_world, l.Name, l.Url ?? _defaultUrl, Download, _closing.Token); + break; + case MediaCommand.Play p: + Play(p.Request); + break; + case MediaCommand.Stop s: + Stop(s.Filter, s.FadeAway, s.FadeOut); + break; + case MediaCommand.Pause pause: + foreach (var playing in _playing.Where(x => !x.Paused && pause.Filter.Matches(x.Request))) + { + playing.Voice.Pause(); + playing.Paused = true; + } + + break; + } + } + + public void Play(MediaRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + // A paused match is resumed, not started again (Mudlet's pause extension). + if (_playing.FirstOrDefault(p => p.Paused && p.Request.Name == request.Name && p.Request.Kind == request.Kind) is { } paused) + { + paused.Paused = false; + paused.Voice.Resume(); + return; + } + + if (request.Kind == MediaKind.Music && request.Continue + && _playing.Any(p => p.Request.Kind == MediaKind.Music && p.Request.Name == request.Name)) + { + return; + } + + if (_playing.Any(p => p.Request.Priority is { } held && held > (request.Priority ?? 0))) + { + return; // something more important is playing + } + + if (request.Exclusive + && _playing.Any(p => p.Request.Exclusive && (p.Request.Priority ?? 0) >= (request.Priority ?? 0))) + { + return; // MSP: a tie keeps what is playing + } + + var generation = ++_generation; + _pending[generation] = request; + var resolving = _cache.ResolveAsync(_world, request.Name, request.Url ?? _defaultUrl, Download, _closing.Token); + if (resolving.IsCompleted) + { + Begin(request, resolving.Result, generation); + return; + } + + _ = Finish(request, resolving, generation); + } + + /// Stops what matches , fading it out first when asked. + public void Stop(MediaFilter filter, bool fadeAway = false, TimeSpan? fadeOut = null) + { + foreach (var (id, waiting) in _pending.ToArray()) + { + if (filter.Matches(waiting)) + { + _pending.Remove(id); + } + } + + foreach (var playing in _playing.Where(p => filter.Matches(p.Request)).ToArray()) + { + var fade = fadeOut ?? (playing.Request.FadeOut > TimeSpan.Zero ? playing.Request.FadeOut : DefaultFadeAway); + if (fadeAway && fade > TimeSpan.Zero) + { + playing.Voice.FadeTo(0f, fade); + var timer = _time.CreateTimer(_ => Run(() => End(playing)), null, fade, Timeout.InfiniteTimeSpan); + playing.Timer = timer; + } + else + { + End(playing); + } + } + } + + /// How long a fade-away lasts when neither the stop nor the media says. + public static readonly TimeSpan DefaultFadeAway = TimeSpan.FromSeconds(1); + + public void Dispose() + { + _closing.Cancel(); + Stop(MediaFilter.All); + _closing.Dispose(); + } + + private async Task Finish(MediaRequest request, Task<(string? Path, string? Problem)> resolving, long generation) + { + (string? Path, string? Problem) result; + try + { + result = await resolving.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + Run(() => Begin(request, result, generation)); + } + + private void Begin(MediaRequest request, (string? Path, string? Problem) resolved, long generation) + { + if (_closing.IsCancellationRequested || !_pending.Remove(generation)) + { + return; + } + + if (resolved.Path is not { } path) + { + Failed?.Invoke(this, (request, resolved.Problem ?? "no file")); + return; + } + + // The rules are applied again: a fetch takes time, and what is playing may have changed while it ran. + if (_playing.Any(p => p.Request.Priority is { } held && held > (request.Priority ?? 0))) + { + return; + } + + if (request.Kind == MediaKind.Music && _playing.FirstOrDefault(p => p.Request.Kind == MediaKind.Music && p.Request.Name == request.Name) is { } same) + { + if (request.Continue) + { + return; + } + + End(same); + } + + foreach (var playing in _playing.ToArray()) + { + var replacedByKey = request.Key is not null && playing.Request.Key == request.Key && playing.Request.Name != request.Name; + var outranked = request.Priority is { } priority && (playing.Request.Priority ?? 0) < priority; + var displaced = request.Exclusive && playing.Request.Exclusive; + if (replacedByKey || outranked || displaced) + { + End(playing); + } + } + + var start = new AudioStart(_mix.For(request), request.Loops, request.Start, request.Finish, request.FadeIn, request.FadeOut); + if (_output.Play(path, start) is not { } voice) + { + Failed?.Invoke(this, (request, "it could not be played")); + return; + } + + var entry = new Playing(request, voice); + _playing.Add(entry); + voice.Ended += (_, _) => Run(() => Forget(entry)); + Started?.Invoke(this, request); + } + + private void End(Playing playing) + { + playing.Timer?.Dispose(); + if (_playing.Remove(playing)) + { + playing.Voice.Dispose(); + } + } + + private void Forget(Playing playing) + { + playing.Timer?.Dispose(); + _playing.Remove(playing); + } + + private void Run(Action action) + { + if (Post is { } post) + { + post(action); + } + else + { + action(); + } + } + + private sealed class Playing(MediaRequest request, IAudioVoice voice) + { + public MediaRequest Request { get; } = request; + + public IAudioVoice Voice { get; } = voice; + + public bool Paused { get; set; } + + public ITimer? Timer { get; set; } + } +} diff --git a/src/SharpMUTerm.Core/Media/MspTrigger.cs b/src/SharpMUTerm.Core/Media/MspTrigger.cs new file mode 100644 index 00000000..7dd4d842 --- /dev/null +++ b/src/SharpMUTerm.Core/Media/MspTrigger.cs @@ -0,0 +1,110 @@ +using System.Globalization; + +namespace SharpMUTerm.Core.Media; + +/// +/// MSP's in-band sound requests: a line that is exactly !!SOUND(name V=… L=… P=… T=… U=…) or +/// !!MUSIC(name V=… L=… C=… T=… U=…), read into the same s MCMP produces, so +/// one queue applies one set of rules. +/// +/// Only on a connection that agreed MSP. The caller checks that; this reads text and cannot tell a +/// server's trigger from one a player typed into a channel, which on any other connection is all it is. +/// +/// +/// MSP's own defaults differ from MCMP's and are filled in here: volume 100, priority 50, one play, and music +/// continuing. A sound is — MSP plays one at a time and lets a new one +/// in only at a higher priority — and music shares one key, so a different file replaces the one playing. +/// Off stops that kind; !!SOUND(Off U=…) also sets the folder later names are fetched from. +/// +/// +/// Not read: wildcards in a name (MSP's "pick one of these at random"), which the cache refuses as a name, +/// and a trigger with text before or after it on its line, which stays text. +/// +/// +public static class MspTrigger +{ + /// The key every MSP music request shares: MSP plays one tune at a time. + public const string MusicKey = "msp:music"; + + /// The command is, or null when it is not a whole MSP trigger. + public static MediaCommand? Parse(string line) + { + ArgumentNullException.ThrowIfNull(line); + var text = line.Trim(); + MediaKind kind; + if (text.StartsWith("!!SOUND(", StringComparison.OrdinalIgnoreCase)) + { + kind = MediaKind.Sound; + } + else if (text.StartsWith("!!MUSIC(", StringComparison.OrdinalIgnoreCase)) + { + kind = MediaKind.Music; + } + else + { + return null; + } + + if (!text.EndsWith(')') || text.IndexOf(')') != text.Length - 1) + { + return null; + } + + var words = text[8..^1].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return null; + } + + var name = words[0]; + var fields = new Dictionary(); + foreach (var word in words.Skip(1)) + { + if (word.Length < 3 || word[1] != '=') + { + return null; + } + + fields[char.ToUpperInvariant(word[0])] = word[2..]; + } + + return FromFields(kind, name, fields); + } + + /// + /// A sound or music request from its parts: a name and MSP's one-letter fields (V L P C T U). MXP's + /// <SOUND> and <MUSIC> carry the same fields as attributes and come through here, so + /// both protocols follow one set of defaults. + /// + public static MediaCommand FromFields(MediaKind kind, string name, IReadOnlyDictionary fields) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(fields); + var url = fields.GetValueOrDefault('U'); + if (string.Equals(name, "Off", StringComparison.OrdinalIgnoreCase)) + { + // The specification's way to set the download folder without playing anything. + return url is not null && kind == MediaKind.Sound + ? new MediaCommand.SetDefault(url) + : new MediaCommand.Stop(new MediaFilter { Kind = kind }); + } + + var request = new MediaRequest(name) + { + Url = url, + Kind = kind, + Tag = fields.GetValueOrDefault('T'), + Volume = Math.Clamp(Number(fields, 'V') ?? 100, 1, 100), + Loops = Number(fields, 'L') is { } loops and (-1 or > 0) ? loops : 1, + }; + + return new MediaCommand.Play(kind == MediaKind.Sound + ? request with { Priority = Math.Clamp(Number(fields, 'P') ?? 50, 1, 100), Exclusive = true } + : request with { Continue = Number(fields, 'C') != 0, Key = MusicKey }); + } + + private static int? Number(IReadOnlyDictionary fields, char key) => + fields.TryGetValue(key, out var value) && int.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var number) + ? number + : null; +} diff --git a/src/SharpMUTerm.Core/Packages/PackageArchive.cs b/src/SharpMUTerm.Core/Packages/PackageArchive.cs new file mode 100644 index 00000000..a4eff4fb --- /dev/null +++ b/src/SharpMUTerm.Core/Packages/PackageArchive.cs @@ -0,0 +1,225 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Core.Packages; + +/// +/// What a package archive holds, read and checked in memory. Nothing in it has touched the disk or the +/// configuration yet: that waits for the player to accept the review. +/// +public sealed record PackageContents( + PackageManifest Manifest, + IReadOnlyList Sets, + IReadOnlyDictionary Scripts, + IReadOnlyDictionary Media, + IReadOnlyList Ignored) +{ + /// What the review screen says the package would add. + public PackageReview Review() => PackageReview.Of(this); +} + +/// +/// A .smtpkg file: a zip holding package.json, sets/*.json in the trigger-set schema, +/// scripts/**.lua and media/**. +/// +/// A package is code from a stranger, and this is the door. Every entry name is relative, made of +/// plain segments (no .., no ., no drive, no backslash), and inside one of the three folders; +/// anything else is left out and listed. Sizes are counted while decompressing, never taken from the +/// archive's own headers, against a per-file and a total cap, so a zip bomb stops at the cap. The manifest +/// is read and validated before any other entry is opened. +/// +/// +public static class PackageArchive +{ + public const string Extension = ".smtpkg"; + + public const long MaxArchiveBytes = 64L * 1024 * 1024; + + public const long MaxTotalBytes = 128L * 1024 * 1024; + + public const long MaxFileBytes = 32L * 1024 * 1024; + + public const long MaxTextBytes = 2L * 1024 * 1024; + + public const int MaxEntries = 1000; + + /// The media a package may carry: the sound types the client plays, and pictures. + public static readonly IReadOnlyList MediaExtensions = ["wav", "mp3", "ogg", "flac", "png", "jpg", "jpeg", "gif", "webp"]; + + public static (PackageContents? Contents, string? Problem) Read(byte[] archive) + { + ArgumentNullException.ThrowIfNull(archive); + if (archive.LongLength > MaxArchiveBytes) + { + return (null, $"the file is larger than {MaxArchiveBytes / (1024 * 1024)} MB"); + } + + try + { + using var zip = new ZipArchive(new MemoryStream(archive, writable: false), ZipArchiveMode.Read); + return Read(zip); + } + catch (InvalidDataException ex) + { + return (null, $"not a package: {ex.Message}"); + } + } + + private static (PackageContents?, string?) Read(ZipArchive zip) + { + if (zip.Entries.Count > MaxEntries) + { + return (null, $"the package has more than {MaxEntries} files"); + } + + var manifestEntry = zip.Entries.FirstOrDefault(e => e.FullName == "package.json"); + if (manifestEntry is null) + { + return (null, "the package has no package.json"); + } + + long total = 0; + var manifestBytes = ReadCapped(manifestEntry, MaxTextBytes, ref total); + if (manifestBytes is null) + { + return (null, "package.json is too large"); + } + + var (manifest, problem) = PackageManifest.Parse(Encoding.UTF8.GetString(manifestBytes)); + if (manifest is null) + { + return (null, problem); + } + + var sets = new List(); + var scripts = new SortedDictionary(StringComparer.Ordinal); + var media = new SortedDictionary(StringComparer.Ordinal); + var ignored = new List(); + foreach (var entry in zip.Entries) + { + var name = entry.FullName; + if (entry == manifestEntry || name.EndsWith('/')) + { + continue; + } + + if (Classify(name) is not { } kind) + { + ignored.Add(name); + continue; + } + + var cap = kind == Kind.Media ? MaxFileBytes : MaxTextBytes; + var bytes = ReadCapped(entry, cap, ref total); + if (bytes is null) + { + return (null, total > MaxTotalBytes + ? $"the package unpacks to more than {MaxTotalBytes / (1024 * 1024)} MB" + : $"{name} is larger than {cap / (1024 * 1024)} MB"); + } + + switch (kind) + { + case Kind.Set: + TriggerSet? set; + try + { + set = JsonSerializer.Deserialize(bytes, ConfigurationStore.SerializerOptions); + } + catch (JsonException ex) + { + return (null, $"{name} is not a trigger set: {ex.Message}"); + } + + if (set is null || string.IsNullOrWhiteSpace(set.Name)) + { + return (null, $"{name} has no set name"); + } + + sets.Add(set); + break; + case Kind.Script: + scripts[name["scripts/".Length..]] = bytes; + break; + default: + media[name["media/".Length..]] = bytes; + break; + } + } + + if (sets.GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1) is { } twice) + { + return (null, $"two sets are called '{twice.Key}'"); + } + + return (new PackageContents(manifest, sets, scripts, media, ignored), null); + } + + private enum Kind + { + Set, + Script, + Media, + } + + /// Which of the three folders a safe entry name is in, or null for a name this reads nothing from. + private static Kind? Classify(string name) + { + if (!IsSafe(name)) + { + return null; + } + + var segments = name.Split('/'); + var extension = Path.GetExtension(name).TrimStart('.').ToLowerInvariant(); + return segments[0] switch + { + "sets" when segments.Length == 2 && extension == "json" => Kind.Set, + "scripts" when segments.Length >= 2 && extension == "lua" => Kind.Script, + "media" when segments.Length >= 2 && MediaExtensions.Contains(extension) => Kind.Media, + _ => null, + }; + } + + /// A relative name of plain segments: no absolute path, drive, backslash, . or ... + public static bool IsSafe(string name) + { + if (string.IsNullOrEmpty(name) || name.Length > 260 || name[0] == '/' || name.Contains('\\') || name.Contains(':')) + { + return false; + } + + foreach (var segment in name.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == ".." || segment.Any(char.IsControl)) + { + return false; + } + } + + return true; + } + + /// The entry's bytes, or null once it passes or the running total passes its own. + private static byte[]? ReadCapped(ZipArchiveEntry entry, long cap, ref long total) + { + using var input = entry.Open(); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + int read; + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (output.Length + read > cap || total > MaxTotalBytes) + { + return null; + } + + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } +} diff --git a/src/SharpMUTerm.Core/Packages/PackageIndex.cs b/src/SharpMUTerm.Core/Packages/PackageIndex.cs new file mode 100644 index 00000000..164d9fe7 --- /dev/null +++ b/src/SharpMUTerm.Core/Packages/PackageIndex.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpMUTerm.Core.Packages; + +/// One package an index lists: its manifest's fields, where the archive is, and the archive's SHA-256. +public sealed record PackageIndexEntry +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required string Version { get; init; } + + public string? Author { get; init; } + + public string? Description { get; init; } + + public List Games { get; init; } = new(); + + public required string Url { get; init; } + + public required string Sha256 { get; init; } + + /// The entry as a manifest, so it is held to the rules a package's own package.json is. + [JsonIgnore] + public PackageManifest Manifest => new() + { + Id = Id, + Name = Name, + Version = Version, + Author = Author, + Description = Description, + Games = Games, + }; + + /// + /// Whether is the file this entry names: its SHA-256 and, inside it, the same id + /// and version. Null when it is; otherwise what differs. + /// + public string? Verify(byte[] archive, PackageManifest? inside) + { + ArgumentNullException.ThrowIfNull(archive); + var hash = Convert.ToHexStringLower(SHA256.HashData(archive)); + if (!string.Equals(hash, Sha256, StringComparison.OrdinalIgnoreCase)) + { + return "the download does not match the index's SHA-256"; + } + + if (inside is not null && (inside.Id != Id || inside.Version != Version)) + { + return $"the archive is {inside.Id} {inside.Version}, not {Id} {Version}"; + } + + return null; + } +} + +/// +/// A package index: {"packages":[…]}, each entry a manifest plus url and sha256. It is +/// fetched only when the player opens Browse packages. An entry that breaks a rule is dropped rather than +/// failing the whole index, and the count of dropped entries is kept so the screen can say so. +/// +public sealed class PackageIndex +{ + /// The cap on an index download. + public const int MaxIndexBytes = 1024 * 1024; + + /// The cap on entries kept from one index. + public const int MaxEntries = 2000; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private PackageIndex(IReadOnlyList entries, int dropped) + { + Entries = entries; + Dropped = dropped; + } + + public IReadOnlyList Entries { get; } + + /// Entries the index listed that were not kept: a bad manifest, a link that is not https, or a bad hash. + public int Dropped { get; } + + private sealed record Document(List? Packages); + + /// The index in , or why it is not one. + public static (PackageIndex? Index, string? Problem) Parse(string json) + { + Document? document; + try + { + document = JsonSerializer.Deserialize(json, Options); + } + catch (JsonException ex) + { + return (null, $"the index does not parse: {ex.Message}"); + } + + if (document?.Packages is not { } packages) + { + return (null, "the index lists no packages"); + } + + var kept = new List(); + var dropped = 0; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var element in packages) + { + PackageIndexEntry? entry; + try + { + entry = element.Deserialize(Options); + } + catch (JsonException) + { + entry = null; + } + + if (entry is null || kept.Count >= MaxEntries || Problem(entry) is not null || !seen.Add(entry.Id)) + { + dropped++; + continue; + } + + kept.Add(entry); + } + + return (new PackageIndex(kept, dropped), null); + } + + /// What is wrong with , or null. + public static string? Problem(PackageIndexEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + if (entry.Manifest.Problem() is { } problem) + { + return problem; + } + + if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + return "the link is not https"; + } + + if (entry.Sha256.Length != 64 || !entry.Sha256.All(Uri.IsHexDigit)) + { + return "the SHA-256 is not 64 hex digits"; + } + + return null; + } + + /// The entries, with those for : first; otherwise by name. + public IReadOnlyList For(string? host, int port) => + Entries + .OrderBy(e => host is not null && e.Games.Any(g => g != "*" && PackageManifest.Matches(g, host, port)) ? 0 : 1) + .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); +} diff --git a/src/SharpMUTerm.Core/Packages/PackageManifest.cs b/src/SharpMUTerm.Core/Packages/PackageManifest.cs new file mode 100644 index 00000000..3900d87b --- /dev/null +++ b/src/SharpMUTerm.Core/Packages/PackageManifest.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace SharpMUTerm.Core.Packages; + +/// +/// A package's package.json: who it is, which games it is for, and the client it needs. Read and +/// checked before anything else in the archive is opened, so a package that cannot say what it is never gets +/// as far as its rules. +/// +public sealed partial record PackageManifest +{ + /// Lower-case letters, digits, dots and dashes; the folder it installs into. + public required string Id { get; init; } + + public required string Name { get; init; } + + /// SemVer: 1.2.3, optionally with a -pre suffix. + public required string Version { get; init; } + + public string? Author { get; init; } + + public string? Description { get; init; } + + /// + /// The servers it is for: host, host:port, *.domain, or * for every game. Its + /// sets apply to the characters of matching worlds and nobody else, so a package for one game cannot send + /// commands into another. + /// + public List Games { get; init; } = new(); + + /// The oldest client version it works with, or null. + public string? MinClient { get; init; } + + public const int MaxIdLength = 64; + + public const int MaxTextLength = 500; + + [GeneratedRegex("^[a-z0-9][a-z0-9.-]*$")] + private static partial Regex IdPattern(); + + [GeneratedRegex(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$")] + private static partial Regex VersionPattern(); + + [GeneratedRegex(@"^(\*|(\*\.)?[A-Za-z0-9.-]+(:\d{1,5})?)$")] + private static partial Regex GamePattern(); + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + /// The manifest in , or the reason it is not one. + public static (PackageManifest? Manifest, string? Problem) Parse(string json) + { + PackageManifest? manifest; + try + { + manifest = JsonSerializer.Deserialize(json, Options); + } + catch (JsonException ex) + { + return (null, $"package.json does not parse: {ex.Message}"); + } + + if (manifest is null) + { + return (null, "package.json is empty"); + } + + return manifest.Problem() is { } problem ? (null, problem) : (manifest, null); + } + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + /// What is wrong with this manifest, or null. + public string? Problem() + { + if (Id.Length > MaxIdLength || !IdPattern().IsMatch(Id) || Id.Contains("..", StringComparison.Ordinal)) + { + return $"the id '{Id}' is not lower-case letters, digits, dots and dashes"; + } + + if (string.IsNullOrWhiteSpace(Name) || Name.Length > MaxTextLength) + { + return "the package has no name"; + } + + if (!VersionPattern().IsMatch(Version)) + { + return $"the version '{Version}' is not of the form 1.2.3"; + } + + if (Games.Count == 0) + { + return "the package names no games (use \"*\" for every game)"; + } + + if (Games.FirstOrDefault(g => !GamePattern().IsMatch(g)) is { } bad) + { + return $"'{bad}' is not a game address"; + } + + if ((Description?.Length ?? 0) > MaxTextLength || (Author?.Length ?? 0) > MaxTextLength) + { + return "the description or author is too long"; + } + + return null; + } + + /// Whether this package is for the server at :. + public bool IsFor(string host, int port) => Games.Any(g => Matches(g, host, port)); + + public static bool Matches(string game, string host, int port) + { + if (game == "*") + { + return true; + } + + var colon = game.LastIndexOf(':'); + var pattern = colon < 0 ? game : game[..colon]; + if (colon >= 0 && game[(colon + 1)..] != port.ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + return false; + } + + return pattern.StartsWith("*.", StringComparison.Ordinal) + ? host.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase) + : string.Equals(pattern, host, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/SharpMUTerm.Core/Packages/PackageReview.cs b/src/SharpMUTerm.Core/Packages/PackageReview.cs new file mode 100644 index 00000000..e8582a82 --- /dev/null +++ b/src/SharpMUTerm.Core/Packages/PackageReview.cs @@ -0,0 +1,39 @@ +namespace SharpMUTerm.Core.Packages; + +/// +/// The numbers the review screen shows before anything is installed. is the one that +/// matters most: a rule that sends a command does so as the player. +/// +public sealed record PackageReview( + int Sets, + int Triggers, + int Aliases, + int Macros, + int Timers, + int Sending, + IReadOnlyList<(string Name, long Bytes)> Scripts, + int MediaFiles, + long MediaBytes, + IReadOnlyList Ignored) +{ + public static PackageReview Of(PackageContents contents) + { + var sets = contents.Sets; + var sending = + sets.Sum(s => s.Triggers.Count(t => !string.IsNullOrWhiteSpace(t.Actions.SendResponse) || !string.IsNullOrWhiteSpace(t.Actions.ScriptCallback))) + + sets.Sum(s => s.Aliases.Count) + + sets.Sum(s => s.Macros.Count(m => !string.IsNullOrWhiteSpace(m.Command))) + + sets.Sum(s => s.Timers.Count(t => !string.IsNullOrWhiteSpace(t.Command))); + return new PackageReview( + sets.Count, + sets.Sum(s => s.Triggers.Count), + sets.Sum(s => s.Aliases.Count), + sets.Sum(s => s.Macros.Count), + sets.Sum(s => s.Timers.Count), + sending, + contents.Scripts.Select(kv => (kv.Key, (long)kv.Value.Length)).ToList(), + contents.Media.Count, + contents.Media.Values.Sum(v => (long)v.Length), + contents.Ignored); + } +} diff --git a/src/SharpMUTerm.Core/Packages/PackageStore.cs b/src/SharpMUTerm.Core/Packages/PackageStore.cs new file mode 100644 index 00000000..c5345dfd --- /dev/null +++ b/src/SharpMUTerm.Core/Packages/PackageStore.cs @@ -0,0 +1,248 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Core.Packages; + +/// One installed package: its manifest and its sets, with script names pointing into its own folder. +public sealed record InstalledPackage(PackageManifest Manifest, IReadOnlyList Sets) +{ + public string Id => Manifest.Id; +} + +/// +/// The installed packages, one folder each under packages/ beside config.json: +/// <id>/package.json, sets/, scripts/, media/. +/// +/// A package's sets are never written into the configuration. They are read from its folder at start +/// and handed to , which the settings screens do not list, so an +/// update replaces them wholesale and can never touch a rule the player wrote. A player who wants to change +/// one copies it into their own sets first. +/// +/// +/// A package's scripts are named package:<id>/<file> in its sets, and that form +/// resolves under that package's own scripts/ folder and nowhere else (). +/// +/// +/// With no root it is memory-only by construction — the mssp: rule: an install holds the sets for the +/// run, writes nothing, and has no scripts or media to offer, which is what a test and a snapshot get. +/// +/// +public sealed class PackageStore +{ + public const string ScriptPrefix = "package:"; + + private readonly string? _root; + private readonly Dictionary _installed = new(StringComparer.Ordinal); + + public PackageStore(string? root = null) + { + _root = string.IsNullOrWhiteSpace(root) ? null : Path.GetFullPath(root); + } + + public ILogger Logger { get; init; } = NullLogger.Instance; + + public bool HasRoot => _root is not null; + + /// The folder beside a configuration file: packages/. + public static string PathFor(string configPath) => + Path.Combine(Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? ".", "packages"); + + public IReadOnlyList Installed => + _installed.Values.OrderBy(p => p.Manifest.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + public InstalledPackage? Find(string id) => _installed.GetValueOrDefault(id); + + /// Each installed package's media/ folder, for the sound cache to look in before fetching. + public IEnumerable MediaFolders => + _root is null ? [] : _installed.Keys.Select(id => Path.Combine(_root, id, "media")).Where(Directory.Exists); + + /// Reads every package folder. One that does not read is skipped and logged, never fatal. + public void Load() + { + _installed.Clear(); + if (_root is null || !Directory.Exists(_root)) + { + return; + } + + foreach (var folder in Directory.EnumerateDirectories(_root)) + { + var name = Path.GetFileName(folder); + if (name.StartsWith('.')) + { + continue; // an install that did not finish + } + + try + { + var (manifest, problem) = PackageManifest.Parse(File.ReadAllText(Path.Combine(folder, "package.json"))); + if (manifest is null || manifest.Id != name) + { + Logger.LogWarning("Package folder {Folder} skipped: {Problem}", folder, problem ?? "its id is not its folder's name"); + continue; + } + + var sets = new List(); + var setsFolder = Path.Combine(folder, "sets"); + if (Directory.Exists(setsFolder)) + { + foreach (var file in Directory.EnumerateFiles(setsFolder, "*.json").Order(StringComparer.Ordinal)) + { + if (JsonSerializer.Deserialize(File.ReadAllText(file), ConfigurationStore.SerializerOptions) is { } set) + { + sets.Add(set); + } + } + } + + _installed[manifest.Id] = new InstalledPackage(manifest, Bind(manifest.Id, sets)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Logger.LogWarning("Package folder {Folder} skipped: {Reason}", folder, ex.Message); + } + } + } + + /// + /// Installs , replacing any version of the same package. The new folder is + /// written beside the old one and swapped in, so a failure part-way leaves the old version in place. + /// Returns the problem, or null. + /// + public string? Install(PackageContents contents) + { + ArgumentNullException.ThrowIfNull(contents); + var id = contents.Manifest.Id; + if (_root is not null) + { + var target = Path.Combine(_root, id); + var staging = Path.Combine(_root, "." + id + ".new"); + try + { + Directory.CreateDirectory(_root); + if (Directory.Exists(staging)) + { + Directory.Delete(staging, recursive: true); + } + + Directory.CreateDirectory(staging); + File.WriteAllText(Path.Combine(staging, "package.json"), contents.Manifest.ToJson()); + for (var i = 0; i < contents.Sets.Count; i++) + { + Write(staging, $"sets/{i:D3}.json", JsonSerializer.SerializeToUtf8Bytes(contents.Sets[i], ConfigurationStore.SerializerOptions)); + } + + foreach (var (name, bytes) in contents.Scripts) + { + Write(staging, "scripts/" + name, bytes); + } + + foreach (var (name, bytes) in contents.Media) + { + Write(staging, "media/" + name, bytes); + } + + var old = Path.Combine(_root, "." + id + ".old"); + if (Directory.Exists(old)) + { + Directory.Delete(old, recursive: true); + } + + if (Directory.Exists(target)) + { + Directory.Move(target, old); + } + + Directory.Move(staging, target); + if (Directory.Exists(old)) + { + Directory.Delete(old, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return $"could not write the package: {ex.Message}"; + } + } + + _installed[id] = new InstalledPackage(contents.Manifest, Bind(id, contents.Sets)); + return null; + } + + /// Removes a package and its folder. Returns the problem, or null. + public string? Remove(string id) + { + if (!_installed.Remove(id)) + { + return $"no package '{id}' is installed"; + } + + if (_root is not null && PackageArchive.IsSafe(id) && Directory.Exists(Path.Combine(_root, id))) + { + try + { + Directory.Delete(Path.Combine(_root, id), recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return $"removed, but its folder could not be deleted: {ex.Message}"; + } + } + + return null; + } + + /// + /// The file a package:<id>/<file> script name points at, inside that package's own + /// scripts/ folder, or null for any other name or one that leaves the folder. + /// + public string? ResolveScript(string file) + { + if (_root is null || !file.StartsWith(ScriptPrefix, StringComparison.Ordinal)) + { + return null; + } + + var rest = file[ScriptPrefix.Length..]; + var slash = rest.IndexOf('/'); + if (slash <= 0) + { + return null; + } + + var id = rest[..slash]; + var relative = rest[(slash + 1)..]; + if (!_installed.ContainsKey(id) || !PackageArchive.IsSafe(relative)) + { + return null; + } + + var folder = Path.Combine(_root, id, "scripts"); + var full = Path.GetFullPath(relative, folder); + return full.StartsWith(folder + Path.DirectorySeparatorChar, StringComparison.Ordinal) ? full : null; + } + + /// + /// The sets as they run: each script name that is a plain relative path becomes this package's + /// package:<id>/…, so a package's set can only name its own scripts. + /// + private static List Bind(string id, IEnumerable sets) => sets.Select(set => + { + var copy = JsonSerializer.Deserialize( + JsonSerializer.SerializeToUtf8Bytes(set, ConfigurationStore.SerializerOptions), ConfigurationStore.SerializerOptions)!; + copy.ScriptFiles = set.ScriptFiles + .Select(f => f.StartsWith("scripts/", StringComparison.Ordinal) ? f["scripts/".Length..] : f) + .Select(f => $"{ScriptPrefix}{id}/{f}") + .ToList(); + return copy; + }).ToList(); + + private static void Write(string root, string relative, byte[] bytes) + { + var path = Path.Combine(root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, bytes); + } +} diff --git a/src/SharpMUTerm.Core/Presence/DiscordIpc.cs b/src/SharpMUTerm.Core/Presence/DiscordIpc.cs new file mode 100644 index 00000000..c0fecdbf --- /dev/null +++ b/src/SharpMUTerm.Core/Presence/DiscordIpc.cs @@ -0,0 +1,316 @@ +using System.Buffers.Binary; +using System.IO.Pipes; +using System.Net.Sockets; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace SharpMUTerm.Core.Presence; + +/// +/// Shows a through the Discord desktop app's local IPC: the discord-ipc-N socket +/// (a named pipe on Windows), a handshake naming the game's application, then SET_ACTIVITY. +/// +/// +/// +/// Nothing leaves the machine from here. The socket is the Discord app's own, on this computer; with no +/// Discord running there is nothing to connect to and nothing happens. The application id is the game's, from +/// its External.Discord.Info: this client registers none of its own. +/// +/// +/// never blocks. It records the latest presence and wakes a worker, which sends only the +/// newest one, so a game that updates its status every second costs one write per second at most and the +/// client's own thread never waits on a socket. A failed connect is not retried until the next update. +/// +/// +public sealed class DiscordIpc : IPresence +{ + private const int Handshake = 0; + private const int Frame = 1; + private const int MaxReply = 64 * 1024; + + private readonly Func> _connect; + private readonly ILogger _logger; + private readonly int _pid; + private readonly SemaphoreSlim _wake = new(0); + private readonly CancellationTokenSource _stop = new(); + private readonly object _gate = new(); + private readonly Task _worker; + private (GamePresence? Presence, bool Set) _pending; + private Stream? _stream; + private string? _connectedFor; + + /// Opens the IPC stream, or returns null when there is none. Defaults to the Discord app's. + /// Where a failure is noted; never the presence itself. + /// The process Discord attributes the activity to. + public DiscordIpc(Func>? connect = null, ILogger? logger = null, int? pid = null) + { + _connect = connect ?? ConnectToDiscordAsync; + _logger = logger ?? NullLogger.Instance; + _pid = pid ?? Environment.ProcessId; + _worker = Task.Run(RunAsync); + } + + public void Show(GamePresence? presence) + { + lock (_gate) + { + _pending = (presence, true); + } + + _wake.Release(); + } + + private async Task RunAsync() + { + var token = _stop.Token; + try + { + while (!token.IsCancellationRequested) + { + await _wake.WaitAsync(token).ConfigureAwait(false); + GamePresence? presence; + lock (_gate) + { + if (!_pending.Set) + { + continue; + } + + presence = _pending.Presence; + _pending = default; + } + + await SendAsync(presence, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + } + + private async Task SendAsync(GamePresence? presence, CancellationToken token) + { + try + { + if (presence is null) + { + if (_stream is not null) + { + await WriteAsync(Frame, Activity(null), token).ConfigureAwait(false); + await ReadReplyAsync(token).ConfigureAwait(false); + } + + return; + } + + if (_stream is null || _connectedFor != presence.ApplicationId) + { + Close(); + _stream = await _connect(token).ConfigureAwait(false); + if (_stream is null) + { + return; + } + + _connectedFor = presence.ApplicationId; + await WriteAsync(Handshake, new JsonObject { ["v"] = 1, ["client_id"] = presence.ApplicationId }, token).ConfigureAwait(false); + await ReadReplyAsync(token).ConfigureAwait(false); + } + + await WriteAsync(Frame, Activity(presence), token).ConfigureAwait(false); + await ReadReplyAsync(token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or SocketException or UnauthorizedAccessException or TimeoutException or InvalidDataException or ObjectDisposedException) + { + _logger.LogDebug(ex, "Discord presence was not updated."); + Close(); + } + } + + /// The SET_ACTIVITY command for a presence, or the one that clears it. + internal JsonObject Activity(GamePresence? presence) + { + JsonObject? activity = null; + if (presence is not null) + { + activity = new JsonObject(); + if (presence.Details is { } details) + { + activity["details"] = details; + } + + if (presence.State is { } state) + { + activity["state"] = state; + } + + if (presence.StartTime is { } start) + { + activity["timestamps"] = new JsonObject { ["start"] = start.ToUnixTimeSeconds() }; + } + + var assets = new JsonObject(); + if (presence.Game is { } game) + { + assets["large_text"] = game; + } + + if (presence.SmallImage is { } small) + { + assets["small_image"] = small; + if (presence.SmallImageText is { } smallText) + { + assets["small_text"] = smallText; + } + } + + if (assets.Count > 0) + { + activity["assets"] = assets; + } + + if (presence is { PartySize: { } size and > 0, PartyMax: { } max } && max >= size) + { + activity["party"] = new JsonObject { ["size"] = new JsonArray(size, max) }; + } + } + + return new JsonObject + { + ["cmd"] = "SET_ACTIVITY", + ["args"] = new JsonObject { ["pid"] = _pid, ["activity"] = activity }, + ["nonce"] = Guid.NewGuid().ToString("N"), + }; + } + + private async Task WriteAsync(int op, JsonObject body, CancellationToken token) + { + var payload = Encoding.UTF8.GetBytes(body.ToJsonString()); + var frame = new byte[8 + payload.Length]; + BinaryPrimitives.WriteInt32LittleEndian(frame, op); + BinaryPrimitives.WriteInt32LittleEndian(frame.AsSpan(4), payload.Length); + payload.CopyTo(frame, 8); + await _stream!.WriteAsync(frame, token).ConfigureAwait(false); + await _stream.FlushAsync(token).ConfigureAwait(false); + } + + /// Reads and discards one reply, so Discord's side never fills; bounded in size and time. + private async Task ReadReplyAsync(CancellationToken token) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + var header = new byte[8]; + try + { + await _stream!.ReadExactlyAsync(header, timeout.Token).ConfigureAwait(false); + var length = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(4)); + if (length is < 0 or > MaxReply) + { + throw new InvalidDataException("Discord sent a reply this client will not read."); + } + + await _stream.ReadExactlyAsync(new byte[length], timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + throw new TimeoutException("Discord did not answer."); + } + catch (EndOfStreamException ex) + { + throw new IOException("Discord closed the connection.", ex); + } + } + + private void Close() + { + _stream?.Dispose(); + _stream = null; + _connectedFor = null; + } + + /// The Discord app's IPC endpoint on this machine, or null when none answers. + private static async Task ConnectToDiscordAsync(CancellationToken token) + { + for (var i = 0; i < 10; i++) + { + try + { + if (OperatingSystem.IsWindows()) + { + var pipe = new NamedPipeClientStream(".", $"discord-ipc-{i}", PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(200, token).ConfigureAwait(false); + return pipe; + } + catch + { + await pipe.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + foreach (var folder in SocketFolders()) + { + var path = Path.Combine(folder, $"discord-ipc-{i}"); + if (!File.Exists(path)) + { + continue; + } + + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(path), token).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + } + } + } + catch (Exception ex) when (ex is IOException or TimeoutException or SocketException or UnauthorizedAccessException) + { + } + } + + return null; + } + + /// Where Discord puts its socket on Linux and macOS, flatpak and snap installs included. + private static IEnumerable SocketFolders() + { + var roots = new[] { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" } + .Select(Environment.GetEnvironmentVariable) + .Where(v => !string.IsNullOrEmpty(v)) + .Cast() + .Append("/tmp") + .Distinct(StringComparer.Ordinal) + .ToList(); + foreach (var root in roots) + { + yield return root; + yield return Path.Combine(root, "app", "com.discordapp.Discord"); + yield return Path.Combine(root, "snap.discord"); + } + } + + public void Dispose() + { + _stop.Cancel(); + try + { + _worker.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException) + { + } + + Close(); + _stop.Dispose(); + _wake.Dispose(); + } +} diff --git a/src/SharpMUTerm.Core/Presence/GamePresence.cs b/src/SharpMUTerm.Core/Presence/GamePresence.cs new file mode 100644 index 00000000..d75442d9 --- /dev/null +++ b/src/SharpMUTerm.Core/Presence/GamePresence.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Core.Presence; + +/// +/// What a game says about itself for a Discord profile, over GMCP's External.Discord (Mudlet's +/// convention): Info names the game's Discord application, Status what the player is doing. +/// +/// The game's Discord application id, from Info; nothing is shown without it. +/// The game's name. +/// The first line under the game's name. +/// The second line. +/// An image key the game's application defines. +/// The tooltip on that image. +/// When the session started, for the elapsed clock. +/// How many are in the player's party. +/// How many the party can hold. +public sealed record GamePresence( + string ApplicationId, + string? Game = null, + string? Details = null, + string? State = null, + string? SmallImage = null, + string? SmallImageText = null, + DateTimeOffset? StartTime = null, + int? PartySize = null, + int? PartyMax = null) +{ + /// The module a world lists in Core.Supports.Set when it may set the presence. + public const string GmcpModule = "External.Discord 1"; + + public const string InfoPackage = "External.Discord.Info"; + public const string StatusPackage = "External.Discord.Status"; + + /// Discord's limit on a presence field. + public const int MaxField = 128; + + /// + /// The presence after arrives, from (null for none yet). + /// Returns for any other package or a body that does not parse. + /// + public static GamePresence? Apply(GamePresence? current, string package, string json) + { + JsonObject? body; + try + { + body = JsonNode.Parse(json) as JsonObject; + } + catch (JsonException) + { + return current; + } + + if (body is null) + { + return current; + } + + if (string.Equals(package, InfoPackage, StringComparison.OrdinalIgnoreCase)) + { + var id = Text(body, "applicationid"); + if (id is null || !id.All(char.IsAsciiDigit) || id.Length > 32) + { + return current; + } + + return current is null ? new GamePresence(id) : current with { ApplicationId = id }; + } + + if (!string.Equals(package, StatusPackage, StringComparison.OrdinalIgnoreCase) || current is null) + { + return current; + } + + return current with + { + Game = Text(body, "game") ?? current.Game, + Details = Text(body, "details") ?? current.Details, + State = Text(body, "state") ?? current.State, + SmallImage = Text(body, "smallimage") ?? current.SmallImage, + SmallImageText = Text(body, "smallimagetext") ?? current.SmallImageText, + StartTime = Number(body, "starttime") is { } start and > 0 + ? DateTimeOffset.FromUnixTimeSeconds(Math.Min(start, 253402300799)) + : current.StartTime, + PartySize = (int?)Number(body, "partysize") ?? current.PartySize, + PartyMax = (int?)Number(body, "partymax") ?? current.PartyMax, + }; + } + + /// A string field, trimmed to one line and Discord's length, or null when absent or empty. + private static string? Text(JsonObject body, string name) + { + if (body[name] is not JsonValue value) + { + return null; + } + + var text = value.TryGetValue(out string? s) ? s + : value.TryGetValue(out long n) ? n.ToString(CultureInfo.InvariantCulture) + : null; + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + text = string.Join(' ', text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)).Trim(); + return text.Length <= MaxField ? text : text[..MaxField]; + } + + private static long? Number(JsonObject body, string name) => + body[name] is JsonValue value + ? value.TryGetValue(out long n) ? Math.Clamp(n, 0, int.MaxValue) + : value.TryGetValue(out string? s) && long.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var p) ? Math.Clamp(p, 0, int.MaxValue) + : null + : null; +} + +/// +/// Where a game's presence is shown. A host supplies one or none; the client calls it on its own thread, and an +/// implementation must not block. +/// +public interface IPresence : IDisposable +{ + /// Shows , or clears what is shown when it is null. + void Show(GamePresence? presence); +} diff --git a/src/SharpMUTerm.Core/Protocols/GameDataStore.cs b/src/SharpMUTerm.Core/Protocols/GameDataStore.cs new file mode 100644 index 00000000..b3224311 --- /dev/null +++ b/src/SharpMUTerm.Core/Protocols/GameDataStore.cs @@ -0,0 +1,289 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Core.Protocols; + +/// One package the store holds: its name as the server spelt it, when it last changed, and its data. +public sealed record GameDataEntry(string Package, DateTimeOffset UpdatedAt, JsonNode? Data); + +/// +/// The latest out-of-band game data a session has received: every GMCP package by name, and every MSDP +/// variable under MSDP.<NAME>. Read by dotted path — Char.Vitals.hp, MSDP.HEALTH — +/// by the status row's meters, the ⌃P inspector and Lua. +/// +/// +/// +/// An object payload is merged into what is held, key by key, the way Mudlet's gmcp table is: +/// Char.Status and its relatives send only what changed, and replacing the package would forget every +/// field the latest message did not repeat. Any other payload replaces the package. +/// +/// +/// Names are case-insensitive because servers disagree: IRE sends Char.Vitals, Aardwolf +/// char.vitals, and a meter configured once should read both. +/// +/// +/// It is bounded at the door: a payload over is refused, and once +/// names are held a new one is refused (an existing one still updates). The server +/// chooses both, so neither may grow memory without limit. +/// +/// Writes arrive on the telnet read loop and reads come from the UI thread; one lock covers both. +/// +public sealed class GameDataStore +{ + /// The largest payload kept: 64 KiB of JSON text. + public const int MaxPayloadBytes = 64 * 1024; + + /// How many package names may be held at once. + public const int MaxPackages = 512; + + /// The prefix MSDP variables are kept under. + public const string MsdpPrefix = "MSDP"; + + /// The prefix MXP entities are kept under. + public const string MxpPrefix = "MXP"; + + private readonly Lock _gate = new(); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly TimeProvider _time; + + public GameDataStore(TimeProvider? time = null) => _time = time ?? TimeProvider.System; + + /// Raised after a package changes, with its name. On the writer's thread. + public event EventHandler? Changed; + + /// How many packages are held. + public int Count + { + get + { + lock (_gate) + { + return _entries.Count; + } + } + } + + /// Every package held, sorted by name. + public IReadOnlyList Entries + { + get + { + lock (_gate) + { + return _entries.Values + .OrderBy(e => e.Package, StringComparer.OrdinalIgnoreCase) + .Select(e => e with { Data = e.Data?.DeepClone() }) + .ToArray(); + } + } + } + + /// + /// Takes one GMCP message. An empty body (GMCP allows a package with no data) is held as null. Returns + /// whether it was kept. + /// + public bool SetGmcp(string package, string? json) + { + if (string.IsNullOrWhiteSpace(package) || package.Length > 128 || (json?.Length ?? 0) > MaxPayloadBytes) + { + return false; + } + + JsonNode? data = null; + if (!string.IsNullOrWhiteSpace(json)) + { + try + { + data = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = 32 }); + } + catch (JsonException) + { + return false; + } + } + + return Store(package.Trim(), data); + } + + /// + /// Takes one MSDP message, as the telnet library renders it: a JSON object of variable → value. Each + /// variable is held as its own package, MSDP.<NAME>. Returns whether anything was kept. + /// + public bool SetMsdp(string? json) + { + if (string.IsNullOrWhiteSpace(json) || json.Length > MaxPayloadBytes) + { + return false; + } + + JsonNode? root; + try + { + root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = 32 }); + } + catch (JsonException) + { + return false; + } + + if (root is not JsonObject variables) + { + return false; + } + + var kept = false; + foreach (var (name, value) in variables.ToArray()) + { + if (name.Length is > 0 and <= 64) + { + kept |= Store($"{MsdpPrefix}.{name}", value?.DeepClone()); + } + } + + return kept; + } + + /// + /// Takes one MXP entity (<!ENTITY>, <VAR>, an element's FLAG="set x"), held as + /// MXP.<name>; null removes it. Returns whether anything changed. + /// + public bool SetMxp(string name, string? value) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 64 || (value?.Length ?? 0) > MaxPayloadBytes) + { + return false; + } + + var package = $"{MxpPrefix}.{name}"; + if (value is null) + { + bool removed; + lock (_gate) + { + removed = _entries.Remove(package); + } + + if (removed) + { + Changed?.Invoke(this, package); + } + + return removed; + } + + return Store(package, JsonValue.Create(value)); + } + + /// + /// The value at a dotted path, or null. The longest package name that prefixes the path wins, and the rest + /// of the path walks into its data: Char.Vitals.hp is field hp of package Char.Vitals. + /// A numeric segment indexes an array. + /// + public JsonNode? Get(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var parts = path.Trim().Split('.'); + lock (_gate) + { + for (var take = parts.Length; take > 0; take--) + { + if (!_entries.TryGetValue(string.Join('.', parts, 0, take), out var entry)) + { + continue; + } + + var node = entry.Data; + for (var i = take; i < parts.Length && node is not null; i++) + { + node = Step(node, parts[i]); + } + + return node?.DeepClone(); + } + } + + return null; + } + + /// + /// The value at a path as display text: a string as itself, a number in invariant culture, a boolean as + /// true/false. Null for a missing value or one that is an object or an array. + /// + public string? GetText(string? path) => Get(path) switch + { + JsonValue value when value.TryGetValue(out string? text) => text, + JsonValue value when value.TryGetValue(out bool flag) => flag ? "true" : "false", + JsonValue value => value.ToJsonString(), + _ => null, + }; + + /// The value at a path as a number, reading numeric strings too (IRE sends "hp": "450"). + public double? GetNumber(string? path) => GetText(path) is { } text + && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var n) + && double.IsFinite(n) + ? n + : null; + + /// Forgets everything, as a new connection does. + public void Clear() + { + lock (_gate) + { + _entries.Clear(); + } + } + + /// The data a package holds, pretty-printed, for the inspector. + public static string Pretty(JsonNode? data) => + data is null ? "(no data)" : data.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + + private bool Store(string package, JsonNode? data) + { + lock (_gate) + { + if (_entries.TryGetValue(package, out var existing)) + { + if (existing.Data is JsonObject held && data is JsonObject update) + { + var merged = (JsonObject)held.DeepClone(); + foreach (var (key, value) in update.ToArray()) + { + merged[key] = value?.DeepClone(); + } + + if (Size(merged) > MaxPayloadBytes) + { + return false; + } + + data = merged; + } + } + else if (_entries.Count >= MaxPackages) + { + return false; + } + + _entries[package] = new GameDataEntry(existing?.Package ?? package, _time.GetUtcNow(), data); + } + + Changed?.Invoke(this, package); + return true; + } + + private static int Size(JsonNode node) => Encoding.UTF8.GetByteCount(node.ToJsonString()); + + private static JsonNode? Step(JsonNode node, string part) => node switch + { + JsonObject obj => obj.FirstOrDefault(p => string.Equals(p.Key, part, StringComparison.OrdinalIgnoreCase)).Value, + JsonArray array when int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var i) + && i < array.Count => array[i], + _ => null, + }; +} diff --git a/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs b/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs new file mode 100644 index 00000000..3848f636 --- /dev/null +++ b/src/SharpMUTerm.Core/Protocols/MxpParser.Definitions.cs @@ -0,0 +1,578 @@ +using System.Text; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Protocols; + +/// A server's entity changed: <!ENTITY>, <VAR> or an element's FLAG="set x". A null value is a deletion. +public sealed record MxpEntityChange(string Name, string? Value); + +/// The text of an element whose FLAG says what it is: RoomName, RoomDesc, RoomExit, RoomNum… +public sealed record MxpFlagText(string Flag, string Text); + +/// A <GAUGE> or <STAT>: show entity (out of ) on the status row. +public sealed record MxpGauge(string Entity, string? Max, string? Caption, bool IsStat); + +/// A <FRAME> the server opened or closed. +public sealed record MxpFrame(string Name, string? Title, bool Open); + +/// Text a <DEST> or a redirecting <FRAME> sent to a named window instead of the main one. +public sealed record MxpRedirect(string Frame, StyledLine Line); + +/// +/// What a server defines and declares over MXP: <!ELEMENT> and <!ENTITY>, <VAR>, +/// <GAUGE>/<STAT>, <SOUND>/<MUSIC>, and <FRAME>/<DEST>. +/// +/// +/// +/// Every one of these is a secure tag, so on an open line it is echoed as text like any other — a player +/// cannot define an element, set an entity or open a window. Definitions are parser state, so they last for the +/// connection and a reconnect, which builds a new parser, starts without them. +/// +/// +/// An element's own tags inherit the trust it was opened with. One opened on a secure line may contain +/// anything; one a player used on an open line, because the server defined it OPEN, may contain only +/// open tags, and anything else in its definition is dropped rather than echoed (the definition is the server's +/// text, not the player's). Attribute values are substituted with < and > taken out, so a +/// value cannot close the tag it lands in and open another. +/// +/// +/// Definitions are bounded so one cannot be used as a bomb: how many elements and entities a connection +/// may hold, how long a definition or value may be, how deep elements may nest inside one another, and how many +/// expansions a single line may cost. +/// +/// +public sealed partial class MxpParser +{ + /// The most elements one connection may define. + public const int MaxElements = 256; + + /// The most entities one connection may define. + public const int MaxEntities = 512; + + /// The longest element definition or entity value kept. + public const int MaxDefinitionLength = 1024; + + /// How deep an element may open other elements. + public const int MaxExpansionDepth = 4; + + /// How many element expansions one line may cost. + public const int MaxExpansionsPerLine = 64; + + /// The most windows one connection's frames may name. + public const int MaxFrames = 8; + + private const int MaxNameLength = 32; + + /// Why a tag was allowed, which an element's own tags inherit. + private enum Trust + { + None, + Open, + Secure, + } + + private sealed record Element( + string Name, + string Definition, + IReadOnlyList<(string Name, string? Default)> Attributes, + string? Flag, + int? LineTag, + bool Open, + bool Empty); + + private readonly Dictionary _elements = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _entities = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _frames = new(StringComparer.OrdinalIgnoreCase); + private string? _redirect; + private bool _markupThisLine; + private int _expansionDepth; + private int _expansionsThisLine; + + /// Raised when a server's entity is set or deleted. Not for one defined PRIVATE. + public event EventHandler? EntityChanged; + + /// Raised when a flagged element closes, with its text. + public event EventHandler? Flagged; + + /// Raised for each <GAUGE> or <STAT>. + public event EventHandler? GaugeDeclared; + + /// Raised for each <SOUND> or <MUSIC>, as the same request MSP and MCMP make. + public event EventHandler? MediaRequested; + + /// Raised when a <FRAME> opens or closes a window. + public event EventHandler? FrameDeclared; + + /// Raised with text a <DEST> or a redirect sent to another window. + public event EventHandler? Redirected; + + /// The value of a server entity, or null. For tests and the inspector. + public string? Entity(string name) => _entities.GetValueOrDefault(name); + + /// How many elements are defined. + public int ElementCount => _elements.Count; + + private void ResetDefinitions() + { + _elements.Clear(); + _entities.Clear(); + _frames.Clear(); + _redirect = null; + _markupThisLine = false; + _expansionDepth = 0; + _expansionsThisLine = 0; + LineSwallowed = false; + } + + /// The names no server may redefine: every tag this parser handles itself, and their spellings. + private static bool IsBuiltIn(string name) => + MxpTagCategory.IsOpen(name) || Canonical(name) is "B" or "I" or "U" or "S" or "COLOR" or "FONT" or "SEND" or "A" + or "BR" or "VERSION" or "SUPPORT" or "IMAGE" or "!ELEMENT" or "!ENTITY" or "VAR" or "GAUGE" or "STAT" + or "SOUND" or "MUSIC" or "FRAME" or "DEST" or "SUPPORTS" or "EXPIRE" or "RESET" or "P" or "NOBR"; + + private static bool IsName(string? name) => + name is { Length: > 0 and <= MaxNameLength } && char.IsLetter(name[0]) + && name.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-'); + + // ----- -------------------------------------------------------- + + /// <!ELEMENT name 'definition' [ATT='a b=default'] [TAG=n] [FLAG=flag] [OPEN] [DELETE] [EMPTY]>. + private void DefineElement(string attrs) + { + var parsed = ParseAttributes(attrs); + var name = Positional(parsed, 0); + if (!IsName(name) || IsBuiltIn(name!)) + { + return; + } + + if (HasFlag(parsed, "DELETE")) + { + _elements.Remove(name!); + return; + } + + var definition = Positional(parsed, 1) ?? string.Empty; + if (definition.Length > MaxDefinitionLength || (!_elements.ContainsKey(name!) && _elements.Count >= MaxElements)) + { + return; + } + + var attributes = (GetAttr(parsed, "ATT") ?? string.Empty) + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Take(16) + .Select(a => a.Split('=', 2) is var kv && kv.Length == 2 ? (kv[0], (string?)kv[1]) : (a, null)) + .Where(a => IsName(a.Item1)) + .ToArray(); + int? lineTag = int.TryParse(GetAttr(parsed, "TAG"), out var tag) && tag is >= 20 and <= 99 ? tag : null; + var flag = GetAttr(parsed, "FLAG") is { Length: > 0 and <= 64 } f ? f : null; + + _elements[name!] = new Element( + Canonical(name!), definition, attributes, flag, lineTag, HasFlag(parsed, "OPEN"), HasFlag(parsed, "EMPTY")); + } + + /// + /// Opens an element: a frame of its own, then the tags of its definition with its attributes filled in. + /// Its closing tag closes them all, because their frames sit above its own on the stack. + /// + private void OpenElement(Element element, string attrs, Trust trust, bool endsWithLine) + { + if (_expansionDepth >= MaxExpansionDepth || _expansionsThisLine >= MaxExpansionsPerLine) + { + return; + } + + _expansionsThisLine++; + var values = ElementValues(element, ParseAttributes(attrs)); + var start = _stack.Count; + PushFrame( + element.Name, isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, + flag: element.Empty ? null : element.Flag, endsWithLine: endsWithLine); // an empty element has no text to flag + + _expansionDepth++; + try + { + foreach (var tag in DefinitionTags(Substitute(element.Definition, values))) + { + ProcessInnerTag(tag, trust); + } + } + finally + { + _expansionDepth--; + } + + if (element.Empty) + { + FlushRun(); + CloseFramesFrom(start); + } + } + + /// What each of an element's attributes is this time: given by name, by position, or its default. + private static Dictionary ElementValues(Element element, List<(string? Key, string Value)> given) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < element.Attributes.Count; i++) + { + var (name, fallback) = element.Attributes[i]; + var value = GetAttr(given, name) ?? Positional(given, i) ?? fallback ?? string.Empty; + values[name] = value.Replace("<", string.Empty, StringComparison.Ordinal).Replace(">", string.Empty, StringComparison.Ordinal); + } + + return values; + } + + /// The definition with each &attribute; replaced; any other entity is left for the tag to read. + private static string Substitute(string definition, Dictionary values) + { + if (values.Count == 0 || !definition.Contains('&', StringComparison.Ordinal)) + { + return definition; + } + + var sb = new StringBuilder(definition.Length); + var i = 0; + while (i < definition.Length) + { + var semi = definition[i] == '&' ? definition.IndexOf(';', i + 1) : -1; + if (semi > i && values.TryGetValue(definition[(i + 1)..semi], out var value)) + { + sb.Append(value); + i = semi + 1; + if (sb.Length > MaxDefinitionLength * 2) + { + break; + } + + continue; + } + + sb.Append(definition[i++]); + } + + return sb.ToString(); + } + + /// The tag bodies in a definition, in order. Text between them is not part of an element. + private static IEnumerable DefinitionTags(string definition) + { + var i = 0; + while ((i = definition.IndexOf('<', i)) >= 0) + { + var end = i + 1; + char? quote = null; + while (end < definition.Length && (quote is not null || definition[end] != '>')) + { + if (definition[end] is '"' or '\'') + { + quote = quote == definition[end] ? null : quote ?? definition[end]; + } + + end++; + } + + if (end >= definition.Length) + { + yield break; + } + + yield return definition[(i + 1)..end]; + i = end + 1; + } + } + + /// + /// One tag of an element's definition. It inherits the element's trust rather than asking the line, and a + /// tag that trust does not allow is dropped, not echoed. Closing tags in a definition are ignored: the + /// element's own closing tag closes everything it opened. + /// + private void ProcessInnerTag(string raw, Trust trust) + { + var trimmed = raw.Trim().TrimEnd('/').TrimEnd(); + if (trimmed.Length == 0 || trimmed[0] == '/') + { + return; + } + + var nameEnd = 0; + while (nameEnd < trimmed.Length && !char.IsWhiteSpace(trimmed[nameEnd])) + { + nameEnd++; + } + + var name = Canonical(trimmed[..nameEnd]); + var allowed = trust == Trust.Secure + || MxpTagCategory.IsOpen(name) + || (_elements.TryGetValue(name, out var inner) && inner.Open); + + // A definition never defines: an element that wrote into itself would grow the table each use. + if (!allowed || name is "!ELEMENT" or "!ENTITY") + { + return; + } + + Open(name, nameEnd < trimmed.Length ? trimmed[(nameEnd + 1)..] : string.Empty, trust); + } + + /// + /// ESC[#z for 10–12 and 20–99: the line's text is a room name, description or exits (the spec's own + /// numbers), or an element the server tied to the number with TAG=. Either way it ends with the line. + /// Line tags come only from the server, so the element opens with secure trust. + /// + private void OpenLineTagElement(int tag) + { + if (tag is >= 10 and <= 12) + { + FlushRun(); + PushFrame( + "LINETAG", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, + flag: tag switch { 10 => "RoomName", 11 => "RoomDesc", _ => "RoomExit" }, endsWithLine: true); + return; + } + + if (_elements.Values.FirstOrDefault(e => e.LineTag == tag) is { } element) + { + FlushRun(); + OpenElement(element, string.Empty, Trust.Secure, endsWithLine: true); + } + } + + // ----- , and FLAG --------------------------------------------- + + /// <!ENTITY name value [DESC=] [PRIVATE] [PUBLISH] [DELETE] [ADD] [REMOVE]>. + private void DefineEntity(string attrs) + { + var parsed = ParseAttributes(attrs); + var name = Positional(parsed, 0); + if (!IsName(name) || ResolveEntity(name!) is not null || string.Equals(name, "text", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var priv = HasFlag(parsed, "PRIVATE"); + if (HasFlag(parsed, "DELETE")) + { + SetEntity(name!, null, priv); + return; + } + + var value = Positional(parsed, 1) ?? string.Empty; + var held = _entities.GetValueOrDefault(name!); + if (HasFlag(parsed, "ADD")) + { + value = string.IsNullOrEmpty(held) ? value : held + "|" + value; + } + else if (HasFlag(parsed, "REMOVE")) + { + value = string.Join('|', (held ?? string.Empty).Split('|').Where(v => v != value)); + } + + SetEntity(name!, value, priv); + } + + /// Sets or deletes an entity, within the caps, and says so unless it is private. + private void SetEntity(string name, string? value, bool isPrivate) + { + if (value is null) + { + if (_entities.Remove(name) && !isPrivate) + { + EntityChanged?.Invoke(this, new MxpEntityChange(name, null)); + } + + return; + } + + if (value.Length > MaxDefinitionLength || (!_entities.ContainsKey(name) && _entities.Count >= MaxEntities)) + { + return; + } + + _entities[name] = value; + if (!isPrivate) + { + EntityChanged?.Invoke(this, new MxpEntityChange(name, value)); + } + } + + /// A server entity's value for &name; in text, or null. Built-in entities are read first. + private string? UserEntity(string name) => _entities.GetValueOrDefault(name); + + /// <VAR name [PRIVATE]>text</VAR>: the text is shown, and stored as the entity. + private void OpenVar(string attrs) + { + var parsed = ParseAttributes(attrs); + if (Positional(parsed, 0) is not { } name || !IsName(name)) + { + return; + } + + PushFrame( + "VAR", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, + flag: (HasFlag(parsed, "PRIVATE") ? "private " : "set ") + name); + } + + /// + /// What a flagged or redirected frame does with its text: at a line boundary it banks this line's share (or, + /// for a <DEST>, sends it); on it finishes. + /// + private void CompleteDefinitionFrame(Frame frame, bool closing) + { + if (frame.Destination is { } destination) + { + FlushRun(); + if (frame.SpanStart < _lineSpans.Count) + { + var spans = _lineSpans.GetRange(frame.SpanStart, _lineSpans.Count - frame.SpanStart); + _lineSpans.RemoveRange(frame.SpanStart, spans.Count); + Redirected?.Invoke(this, new MxpRedirect(destination, new StyledLine(spans))); + } + + return; + } + + if (frame.Flag is not { } flag || frame.Captured is not { } captured) + { + return; + } + + FlushRun(); + for (var i = frame.SpanStart; i < _lineSpans.Count; i++) + { + captured.Append(_lineSpans[i].Text); + } + + if (!closing) + { + captured.Append('\n'); + return; + } + + var text = captured.ToString().TrimEnd('\n'); + captured.Clear(); + if (flag.StartsWith("set ", StringComparison.OrdinalIgnoreCase) || flag.StartsWith("private ", StringComparison.OrdinalIgnoreCase)) + { + var space = flag.IndexOf(' ', StringComparison.Ordinal); + var name = flag[(space + 1)..].Trim(); + if (IsName(name)) + { + SetEntity(name, text, isPrivate: flag[0] is 'p' or 'P'); + } + + return; + } + + Flagged?.Invoke(this, new MxpFlagText(flag, text)); + } + + // ----- , , , --------------------------------------- + + /// <GAUGE entity [MAX=entity] [CAPTION=text] [COLOR=colour]>, and <STAT> the same. + private void DeclareGauge(string attrs, bool isStat) + { + var parsed = ParseAttributes(attrs); + var entity = GetAttr(parsed, "ENTITY") ?? Positional(parsed, 0); + var max = GetAttr(parsed, "MAX") ?? Positional(parsed, 1); + if (!IsName(entity) || (max is not null && !IsName(max))) + { + return; + } + + var caption = GetAttr(parsed, "CAPTION") ?? Positional(parsed, 2); + GaugeDeclared?.Invoke(this, new MxpGauge(entity!, max, caption is { Length: > 0 } ? caption[..Math.Min(caption.Length, 64)] : null, isStat)); + } + + /// <SOUND fname V= L= P= T= U=> and <MUSIC fname V= L= C= T= U=>, MSP's fields as attributes. + private void RequestMedia(MediaKind kind, string attrs) + { + var parsed = ParseAttributes(attrs); + if ((GetAttr(parsed, "FNAME") ?? Positional(parsed, 0)) is not { Length: > 0 and <= 256 } name) + { + return; + } + + var fields = new Dictionary(); + foreach (var (key, value) in parsed) + { + if (key is { Length: 1 }) + { + fields[char.ToUpperInvariant(key[0])] = value; + } + } + + MediaRequested?.Invoke(this, MspTrigger.FromFields(kind, name, fields)); + } + + /// A frame's name as a window: one of this connection's few, or null for the main window. + private string? FrameName(string? name) + { + if (name is null || name.Equals("_top", StringComparison.OrdinalIgnoreCase) || name.Equals("_previous", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!IsName(name)) + { + return null; + } + + if (_frames.Contains(name)) + { + return name; + } + + if (_frames.Count >= MaxFrames) + { + return null; + } + + _frames.Add(name); + return name; + } + + /// + /// <FRAME name [ACTION=OPEN|CLOSE|REDIRECT] [TITLE=…]>. A frame is a window; OPEN asks for one, + /// CLOSE closes it, and REDIRECT sends every following line there until a redirect to _top. + /// Layout attributes (ALIGN, LEFT, WIDTH…) are read and ignored: where a window goes is the player's. + /// + private void DeclareFrame(string attrs) + { + var parsed = ParseAttributes(attrs); + var raw = GetAttr(parsed, "NAME") ?? Positional(parsed, 0); + var action = (GetAttr(parsed, "ACTION") ?? (HasFlag(parsed, "REDIRECT") ? "REDIRECT" : "OPEN")).ToUpperInvariant(); + if (action == "REDIRECT") + { + _redirect = FrameName(raw); + return; + } + + if (FrameName(raw) is not { } name) + { + return; + } + + if (action == "CLOSE") + { + _frames.Remove(name); + if (string.Equals(_redirect, name, StringComparison.OrdinalIgnoreCase)) + { + _redirect = null; + } + } + + var title = GetAttr(parsed, "TITLE") is { Length: > 0 } t ? t[..Math.Min(t.Length, 64)] : null; + FrameDeclared?.Invoke(this, new MxpFrame(name, title, action != "CLOSE")); + } + + /// <DEST name>text</DEST>: the text goes to that window and not to this line. + private void OpenDest(string attrs) + { + var parsed = ParseAttributes(attrs); + var name = FrameName(GetAttr(parsed, "NAME") ?? Positional(parsed, 0)); + + // A DEST to the main window, or past the cap, is a formatting no-op: its text stays where it is. + PushFrame("DEST", isInteraction: false, isLink: false, deferCommand: false, hint: null, promptOnly: false, destination: name); + } +} diff --git a/src/SharpMUTerm.Core/Protocols/MxpParser.cs b/src/SharpMUTerm.Core/Protocols/MxpParser.cs index b35fce2d..1142c405 100644 --- a/src/SharpMUTerm.Core/Protocols/MxpParser.cs +++ b/src/SharpMUTerm.Core/Protocols/MxpParser.cs @@ -45,7 +45,7 @@ namespace SharpMUTerm.Core.Protocols; /// capped to avoid runaway on malformed input. /// /// -public sealed class MxpParser : ILineParser +public sealed partial class MxpParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; @@ -65,7 +65,7 @@ private enum Mode private const int MaxSequenceLength = 128; /// A single open MXP element on the tag stack. - private sealed class Frame + private sealed record Frame { public required string Name { get; init; } public TextStyle SavedStyle { get; init; } @@ -77,6 +77,24 @@ private sealed class Frame public bool PromptOnly { get; init; } public int SpanStart { get; init; } + /// A <SEND>'s commands as written, when one holds &text; to fill in at the close. + public IReadOnlyList? Templates { get; init; } + + /// The menu labels a <SEND>'s hint gave its commands, if it gave any. + public IReadOnlyList? Labels { get; init; } + + /// An element's FLAG: what its text is (RoomName…), or set name to store it. + public string? Flag { get; init; } + + /// A <DEST>'s window: the frame's text is cut from the line and delivered there. + public string? Destination { get; init; } + + /// Closed at the end of its line whatever the mode: opened by an ESC[#z line tag. + public bool EndsWithLine { get; init; } + + /// The text of earlier lines, for a flagged frame that spans a line boundary. + public StringBuilder? Captured { get; init; } + /// /// True when the element was opened while the line was in — /// i.e. it is one of the spec's "tags that were used while in open mode", the only ones it @@ -101,6 +119,10 @@ private sealed class Frame private SpanInteraction? _interaction; private readonly StringBuilder _run = new(); private readonly StringBuilder _tag = new(); + + // The quote an attribute value inside the tag being read is open in, or '\0'. A '>' inside one does not + // end the tag: an definition carries markup in a quoted value. + private char _tagQuote; private readonly StringBuilder _entity = new(); private readonly StringBuilder _seq = new(); private readonly List _lineSpans = new(); @@ -152,7 +174,8 @@ private sealed class Frame /// dropped — so it is deliberately not claimed here. /// private static readonly string[] SupportedTags = - ["+b", "+i", "+u", "+s", "+color", "+font", "+send", "+a", "+br", "+image"]; + ["+b", "+i", "+u", "+s", "+color", "+font", "+send", "+a", "+br", "+image", + "+!element", "+!entity", "+var", "+gauge", "+stat", "+sound", "+music", "+frame", "+dest"]; /// True when a partial line, an open tag/entity, or unclosed markup is buffered. public bool HasPendingContent => @@ -193,16 +216,35 @@ public IReadOnlyList Feed(ReadOnlySpan text) public StyledLine? Flush() { EndLine(); + var markup = _markupThisLine; + _markupThisLine = false; + LineSwallowed = false; if (_lineSpans.Count == 0) { + // A line that was all markup — definitions, a VERSION request, a DEST that took everything — is + // not a blank line the server sent, and a session should not print one for it. + LineSwallowed = markup; return null; } var line = new StyledLine(_lineSpans); _lineSpans.Clear(); + if (_redirect is { } frame) + { + Redirected?.Invoke(this, new MxpRedirect(frame, line)); + LineSwallowed = true; + return null; + } + return line; } + /// + /// Whether the line the last ended was taken entirely by markup or sent to another + /// window, so that its returning null is not a blank line. + /// + public bool LineSwallowed { get; private set; } + /// Resets all parser state, including the current style and the open-tag stack. public void Reset() { @@ -216,10 +258,12 @@ public void Reset() _interaction = null; _run.Clear(); _tag.Clear(); + _tagQuote = '\0'; _entity.Clear(); _seq.Clear(); _lineSpans.Clear(); _stack.Clear(); + ResetDefinitions(); } private void Process(char ch) @@ -318,6 +362,7 @@ private void ProcessText(char ch) { case '<': _tag.Clear(); + _tagQuote = '\0'; _mode = Mode.Tag; break; @@ -508,6 +553,7 @@ private void ApplyLineTag(string parameters) case 5: SetLineMode(MxpLineMode.Open); _defaultMode = MxpLineMode.Open; break; case 6: SetLineMode(MxpLineMode.Secure); _defaultMode = MxpLineMode.Secure; break; case 7: SetLineMode(MxpLineMode.Locked); _defaultMode = MxpLineMode.Locked; break; + case >= 10 and <= 99: OpenLineTagElement(tag); break; } } @@ -585,16 +631,23 @@ private void ApplyReset() /// on because only ever folds an open tag's alternative spellings onto /// another open tag (BOLD → B, C → COLOR); no secure tag can canonicalise into the allow-list. /// - private bool ConsumeAuthorizationFor(string name) + private bool ConsumeAuthorizationFor(string name) => Authorize(name) != Trust.None; + + /// + /// , saying why a tag is allowed: a secure line (or TEMP + /// SECURE) allows anything, an open one allows the open tags and the elements a server defined + /// OPEN. The difference matters to an element's own tags, which inherit the trust it was opened with. + /// + private Trust Authorize(string name) { if (_lineMode == MxpLineMode.Secure || _tempSecure) { // TEMP SECURE is spent by the next tag whatever that tag turns out to be. _tempSecure = false; - return true; + return Trust.Secure; } - return MxpTagCategory.IsOpen(name); + return MxpTagCategory.IsOpen(name) || (_elements.TryGetValue(name, out var element) && element.Open) ? Trust.Open : Trust.None; } private void ProcessOsc(char ch) @@ -647,35 +700,56 @@ private void ProcessTagChar(char ch) switch (ch) { - case '>': - ProcessTag(_tag.ToString()); + case '>' when _tagQuote == '\0': + // Back in text before the tag runs: a
ends the line, and the end of a line abandons a tag + // still being read. + var tag = _tag.ToString(); _tag.Clear(); _mode = Mode.Text; + ProcessTag(tag); break; case '\n': // A newline inside a "tag" means it was never a tag; bail out literally. - _run.Append('<').Append(_tag); - _tag.Clear(); - _mode = Mode.Text; + AbandonTag(); ProcessText('\n'); break; default: if (_tag.Length >= MaxTagLength) { - _run.Append('<').Append(_tag); - _tag.Clear(); - _mode = Mode.Text; + AbandonTag(); ProcessText(ch); return; } + if (ch is '"' or '\'') + { + // A quote opens only where a value can start, so the apostrophe in "" opens nothing. + if (_tagQuote == '\0' && (_tag[^1] == '=' || char.IsWhiteSpace(_tag[^1]))) + { + _tagQuote = ch; + } + else if (_tagQuote == ch) + { + _tagQuote = '\0'; + } + } + _tag.Append(ch); break; } } + /// What was read as a tag was never one: it goes out as the text it was. + private void AbandonTag() + { + _run.Append('<').Append(_tag); + _tag.Clear(); + _tagQuote = '\0'; + _mode = Mode.Text; + } + private void ProcessEntityChar(char ch) { if (ch == ';') @@ -684,7 +758,7 @@ private void ProcessEntityChar(char ch) _entity.Clear(); _mode = Mode.Text; - var replacement = ResolveEntity(content); + var replacement = ResolveEntity(content) ?? UserEntity(content); if (replacement is not null) { // An entity is text like any other:   pays a blank a picture's label owes, anything else ends the debt. @@ -710,7 +784,7 @@ private void ProcessEntityChar(char ch) return; } - if ((char.IsLetterOrDigit(ch) || ch == '#') && _entity.Length < MaxEntityLength) + if ((char.IsLetterOrDigit(ch) || ch == '#' || ch == '_') && _entity.Length < MaxEntityLength) { _entity.Append(ch); return; @@ -781,6 +855,8 @@ private void ProcessTag(string raw) return; } + _markupThisLine = true; + if (trimmed[0] == '/') { // A closing tag takes the category of the element it closes, so the slash comes off @@ -823,13 +899,26 @@ private void ProcessTag(string raw) } var name = Canonical(trimmed[..nameEnd]); - if (!ConsumeAuthorizationFor(name)) + var trust = Authorize(name); + if (trust == Trust.None) { EmitLiteralTag(raw); return; } var attrs = nameEnd < trimmed.Length ? trimmed[(nameEnd + 1)..] : string.Empty; + Open(name, attrs, trust); + } + + /// Opens a tag that has been allowed: a server-defined element, or one of the built-ins. + private void Open(string name, string attrs, Trust trust) + { + if (_elements.TryGetValue(name, out var element)) + { + OpenElement(element, attrs, trust, endsWithLine: false); + return; + } + HandleOpener(name, attrs); } @@ -877,6 +966,29 @@ private void HandleOpener(string name, string attrs) case "IMAGE": HandleImage(attrs); break; + case "!ELEMENT": + DefineElement(attrs); + break; + case "!ENTITY": + DefineEntity(attrs); + break; + case "VAR": + OpenVar(attrs); + break; + case "GAUGE": + case "STAT": + DeclareGauge(attrs, isStat: name == "STAT"); + break; + case "SOUND": + case "MUSIC": + RequestMedia(name == "MUSIC" ? Media.MediaKind.Music : Media.MediaKind.Sound, attrs); + break; + case "FRAME": + DeclareFrame(attrs); + break; + case "DEST": + OpenDest(attrs); + break; default: // Unknown/unsupported tag (VAR, EXPIRE, H1, P, …) — consumed and ignored. break; @@ -1006,6 +1118,15 @@ private void OpenColor(string name, string attrs) } } + /// The most commands one <SEND> menu keeps. + public const int MaxSendChoices = 20; + + /// + /// <SEND [HREF=]"cmd|cmd…" [HINT=]"…" [PROMPT]>. Several |-separated commands make a + /// menu: a click sends the first, and the client offers the rest. A hint with one part more than there + /// are commands is the tooltip followed by a label for each; with as many parts, it is the labels alone. + /// &text; in a command is the element's own text, filled in when the element closes. + /// private void OpenSend(string attrs) { var parsed = ParseAttributes(attrs); @@ -1013,21 +1134,49 @@ private void OpenSend(string attrs) var hint = GetAttr(parsed, "HINT") ?? Positional(parsed, 1); var prompt = HasFlag(parsed, "PROMPT"); - if (href is not null) - { - // HREF may hold several '|'-separated commands; the first is the primary command. - var primary = href.Split('|', 2)[0]; - PushFrame("SEND", isInteraction: true, isLink: false, deferCommand: false, hint: hint, promptOnly: prompt); - _interaction = SpanInteraction.Command(primary, hint, prompt); - } - else + if (href is null) { // No HREF: the enclosed text is itself the command (resolved when the tag closes). PushFrame("SEND", isInteraction: true, isLink: false, deferCommand: true, hint: hint, promptOnly: prompt); _interaction = SpanInteraction.Command(string.Empty, hint, prompt); + return; + } + + var commands = href.Split('|').Take(MaxSendChoices).ToArray(); + string[]? labels = null; + if (commands.Length > 1 && hint is not null) + { + var parts = hint.Split('|'); + if (parts.Length > commands.Length) + { + hint = parts[0]; + labels = parts[1..(commands.Length + 1)]; + } + else if (parts.Length == commands.Length) + { + hint = null; + labels = parts; + } } + + var templated = commands.Any(c => c.Contains(TextEntity, StringComparison.OrdinalIgnoreCase)); + PushFrame( + "SEND", isInteraction: true, isLink: false, deferCommand: templated, hint: hint, promptOnly: prompt, + templates: templated ? commands : null, labels: labels); + _interaction = SendInteraction(commands, labels, hint, prompt); } + /// The placeholder a <SEND> command uses for the element's own text. + private const string TextEntity = "&text;"; + + private static SpanInteraction SendInteraction(IReadOnlyList commands, IReadOnlyList? labels, string? hint, bool prompt) => + SpanInteraction.Command(commands[0], hint, prompt) with + { + Choices = commands.Count < 2 + ? null + : commands.Select((c, i) => new SendChoice(labels is not null && labels[i].Length > 0 ? labels[i] : c, c)).ToArray(), + }; + private void OpenLink(string attrs) { var parsed = ParseAttributes(attrs); @@ -1046,7 +1195,18 @@ private void OpenLink(string attrs) } } - private void PushFrame(string name, bool isInteraction, bool isLink, bool deferCommand, string? hint, bool promptOnly) + private void PushFrame( + string name, + bool isInteraction, + bool isLink, + bool deferCommand, + string? hint, + bool promptOnly, + IReadOnlyList? templates = null, + IReadOnlyList? labels = null, + string? flag = null, + string? destination = null, + bool endsWithLine = false) { FlushRun(); _stack.Add(new Frame @@ -1061,6 +1221,12 @@ private void PushFrame(string name, bool isInteraction, bool isLink, bool deferC PromptOnly = promptOnly, SpanStart = _lineSpans.Count, OpenedInOpenMode = _lineMode == MxpLineMode.Open, + Templates = templates, + Labels = labels, + Flag = flag, + Destination = destination, + EndsWithLine = endsWithLine, + Captured = flag is null ? null : new StringBuilder(), }); } @@ -1111,6 +1277,7 @@ private void CloseFramesFrom(int idx) FinalizeDeferredInteraction(frame); } + CompleteDefinitionFrame(frame, closing: true); _stack.RemoveAt(i); } @@ -1139,7 +1306,13 @@ private void FinalizeDeferredInteraction(Frame frame) var target = sb.ToString(); var interaction = frame.IsLink ? SpanInteraction.Link(target, frame.Hint) - : SpanInteraction.Command(target, frame.Hint, frame.PromptOnly); + : frame.Templates is { } templates + ? SendInteraction( + templates.Select(t => t.Replace(TextEntity, target, StringComparison.OrdinalIgnoreCase)).ToArray(), + frame.Labels, + frame.Hint, + frame.PromptOnly) + : SpanInteraction.Command(target, frame.Hint, frame.PromptOnly); for (var i = frame.SpanStart; i < _lineSpans.Count; i++) { @@ -1173,24 +1346,15 @@ private void CloseInteractionsAtBoundary() // A formatting frame opened *inside* an interaction still holds it in SavedInteraction; // if it closes on a later line, CloseTag would resurrect the ended interaction. Clear it - // (and rebase SpanStart, which pointed into the now-cleared line) on surviving frames. + // (and rebase SpanStart, which pointed into the now-cleared line) on surviving frames. A flagged + // or redirected frame takes this line's share of its text with it first. for (var i = 0; i < _stack.Count; i++) { var frame = _stack[i]; + CompleteDefinitionFrame(frame, closing: false); if (frame.SavedInteraction is not null || frame.SpanStart != 0) { - _stack[i] = new Frame - { - Name = frame.Name, - SavedStyle = frame.SavedStyle, - SavedInteraction = null, - IsInteraction = frame.IsInteraction, - IsLink = frame.IsLink, - DeferCommand = frame.DeferCommand, - Hint = frame.Hint, - PromptOnly = frame.PromptOnly, - SpanStart = 0, - }; + _stack[i] = frame with { SavedInteraction = null, SpanStart = 0 }; } } @@ -1203,6 +1367,13 @@ private void CompleteLine() var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); _lineSpans.Clear(); + _markupThisLine = false; + if (_redirect is { } frame && line.Spans.Count > 0) + { + Redirected?.Invoke(this, new MxpRedirect(frame, line)); + return; + } + (_emit ??= new List()).Add(line); } @@ -1226,9 +1397,27 @@ private void CompleteLine() /// private void EndLine() { + // Nor can a tag: the '\n' rule in ProcessTagChar never runs in production, so without this an + // unclosed '<' went on reading the next line as part of its tag. + if (_mode == Mode.Tag) + { + AbandonTag(); + } + FlushRun(); _owedBlanks = 0; _owedBy = -1; + _expansionsThisLine = 0; + + // An element a line tag opened lasts until the end of its line, in any mode. + for (var i = 0; i < _stack.Count; i++) + { + if (_stack[i].EndsWithLine) + { + CloseFramesFrom(i); + break; + } + } // Spec: "when in OPEN mode, any unclosed OPEN tags are automatically closed when a newline is // received from the MUD." Only open-mode ones: "secure tags are never automatically closed", @@ -1271,6 +1460,9 @@ private void FlushRun() "U" or "UNDERLINE" => "U", "S" or "STRIKEOUT" => "S", "C" or "COLOR" => "COLOR", + "!EL" or "!ELEMENT" => "!ELEMENT", + "!EN" or "!ENTITY" => "!ENTITY", + "V" or "VAR" => "VAR", var other => other, }; diff --git a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs index 99c5d6ec..af9e029c 100644 --- a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs +++ b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs @@ -22,26 +22,29 @@ namespace SharpMUTerm.Core.Protocols; /// deciding whether a world is Pueblo-enabled is a session concern handled elsewhere. /// /// -/// ANSI/VT escape sequences are not decoded, and nothing else decodes them either. An ESC -/// (0x1b) byte is passed through untouched into the span text — so a Pueblo world's colour arrives on -/// screen as a literal <ESC>[0;33m. This used to claim the escapes were "handled -/// upstream"; there is no upstream. WorldSession.CreateParser hands a session one parser -/// for its content format, never a chain, which is the same false premise -/// carried until it grew its own escape state machine (through -/// ). Doing the same here is the fix; it is a known gap -/// rather than a design, and the behaviour clause above is what actually happens today. +/// ANSI is decoded here, through , because a session has one parser and nothing +/// chains another in front of it ('s reason, in the same words). SGR changes the style; +/// every other CSI, OSC, DCS/SOS/PM/APC string and two-byte escape is consumed. The escape state machine is a +/// near-copy of 's and 's, deliberately not shared — a fix to +/// one belongs in all three, with its own test in each suite. /// ///
public sealed class PuebloParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; + private const int MaxSequenceLength = 128; private enum State { Ground, Tag, Entity, + Escape, + EscapeIntermediate, + Csi, + Osc, + OscEscape, } /// A saved rendition frame, pushed when an element opens and restored when it closes. @@ -68,6 +71,7 @@ public Frame(string name, TextStyle style, SpanInteraction? interaction) private readonly List _lineSpans = new(); private readonly StringBuilder _tag = new(); private readonly StringBuilder _entity = new(); + private readonly StringBuilder _seq = new(); private readonly List _stack = new(); /// The rendition state that will apply to the next printed character. @@ -102,6 +106,13 @@ public IReadOnlyList Feed(ReadOnlySpan text) /// public StyledLine? Flush() { + // The telnet layer strips the terminator, so this is the line boundary in production, and an + // escape cannot span it — see Process. + if (IsEscapeState(_state)) + { + EndSequence(); + } + FlushRun(); CloseInteractionsAtBoundary(); if (_lineSpans.Count == 0) @@ -124,11 +135,19 @@ public void Reset() _lineSpans.Clear(); _tag.Clear(); _entity.Clear(); + _seq.Clear(); _stack.Clear(); } private void Process(char ch, ref List? lines) { + // A newline ends the line whatever sequence is in flight, or an unterminated ESC ] that a player + // typed would swallow every following line. MxpParser and AnsiParser carry the same rule. + if (ch == '\n' && IsEscapeState(_state)) + { + EndSequence(); + } + switch (_state) { case State.Ground: @@ -142,9 +161,135 @@ private void Process(char ch, ref List? lines) case State.Entity: ProcessEntity(ch, ref lines); break; + + case State.Escape: + ProcessEscape(ch); + break; + + case State.EscapeIntermediate: + // The single trailing byte of e.g. ESC ( B. + EndSequence(); + break; + + case State.Csi: + ProcessCsi(ch); + break; + + case State.Osc: + ProcessOsc(ch); + break; + + case State.OscEscape: + // ESC inside a string sequence; a following '\' is the ST terminator. + if (ch == '\\') + { + EndSequence(); + } + else + { + _state = State.Osc; + } + + break; } } + private static bool IsEscapeState(State state) => + state is State.Escape or State.EscapeIntermediate or State.Csi or State.Osc or State.OscEscape; + + private void ProcessEscape(char ch) + { + switch (ch) + { + case '[': + _seq.Clear(); + _state = State.Csi; + break; + + case ']': + case 'P': // DCS + case 'X': // SOS + case '^': // PM + case '_': // APC + // String sequences, terminated by BEL or ST — consumed so the payload never shows. + _seq.Clear(); + _state = State.Osc; + break; + + case '(': + case ')': + case '*': + case '+': + case '#': + case '%': + _state = State.EscapeIntermediate; + break; + + default: + // A two-byte escape (ESC c, ESC M, …), consumed. + EndSequence(); + break; + } + } + + private void ProcessCsi(char ch) + { + if (ch is >= '\x40' and <= '\x7e') + { + if (ch == 'm') + { + // Text so far keeps the style it was written in. + FlushRun(); + _current = SgrCodes.Apply(_current, _seq.ToString()); + } + + // Every other final byte — cursor movement, erase — means nothing to a line-oriented view. + EndSequence(); + return; + } + + if (ch is >= '\x20' and <= '\x3f') + { + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + return; + } + + // A control character inside the sequence aborts it as malformed. + EndSequence(); + } + + private void ProcessOsc(char ch) + { + switch (ch) + { + case '\x07': + EndSequence(); + break; + + case '\x1b': + _state = State.OscEscape; + break; + + default: + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + break; + } + } + + private void EndSequence() + { + _seq.Clear(); + _state = State.Ground; + } + private void ProcessGround(char ch, ref List? lines) { switch (ch) @@ -167,6 +312,11 @@ private void ProcessGround(char ch, ref List? lines) // Carriage returns are dropped; scrollback is line-based. break; + case '\x1b': + _seq.Clear(); + _state = State.Escape; + break; + default: _run.Append(ch); break; diff --git a/src/SharpMUTerm.Core/Session/SendGroup.cs b/src/SharpMUTerm.Core/Session/SendGroup.cs new file mode 100644 index 00000000..e0cf1834 --- /dev/null +++ b/src/SharpMUTerm.Core/Session/SendGroup.cs @@ -0,0 +1,134 @@ +namespace SharpMUTerm.Core.Session; + +/// +/// The characters one line can be sent to at once (⌃P ▸ Send the command line to the group), and how a +/// script names another character (world.sendTo). What BeipMU calls puppets: a MUSH player's alts, moved +/// together. +/// +/// +/// +/// Held by session key, in memory, for the life of the run. A group is a thing a player assembles for a +/// scene, and a saved one would be a standing instruction to send one line to several worlds that nobody +/// remembers making. +/// +/// +/// Each member gets the line once, and a disconnected one gets nothing and is named in the result, so a +/// send that reached two of three says which one it missed rather than looking as if it reached everybody. +/// +/// +public sealed class SendGroup +{ + private readonly object _gate = new(); + private readonly List _keys = []; + + /// The members, in the order they were added. + public IReadOnlyList Keys + { + get + { + lock (_gate) + { + return _keys.ToArray(); + } + } + } + + public int Count + { + get + { + lock (_gate) + { + return _keys.Count; + } + } + } + + public bool Contains(string sessionKey) + { + lock (_gate) + { + return _keys.Contains(sessionKey, StringComparer.Ordinal); + } + } + + /// Adds the character, or removes it when it is already a member. Returns whether it is now one. + public bool Toggle(string sessionKey) + { + lock (_gate) + { + if (_keys.Remove(sessionKey)) + { + return false; + } + + _keys.Add(sessionKey); + return true; + } + } + + /// Takes a character out of the group, if it was in it. + public bool Remove(string sessionKey) + { + lock (_gate) + { + return _keys.Remove(sessionKey); + } + } + + public void Clear() + { + lock (_gate) + { + _keys.Clear(); + } + } + + /// + /// The members a line can go to now, once each and in group order, and the names of those it cannot. + /// + public (IReadOnlyList Reachable, IReadOnlyList Missed) Resolve(IReadOnlyList sessions) + { + var reachable = new List(); + var missed = new List(); + foreach (var key in Keys) + { + var session = sessions.FirstOrDefault(s => string.Equals(s.SessionKey, key, StringComparison.Ordinal)); + if (session is { IsConnected: true } && !reachable.Contains(session)) + { + reachable.Add(session); + } + else + { + missed.Add(session?.Character?.Name ?? key); + } + } + + return (reachable, missed); + } + + /// + /// The open session a script means by : a World.Character key exactly, else a + /// character name, ignoring case. Returns null and why when nothing, or more than one, matches, or when the + /// one it names is not connected. + /// + public static (WorldSession? Session, string? Refusal) Find(IReadOnlyList sessions, string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return (null, "a character name is required"); + } + + var byKey = sessions.FirstOrDefault(s => string.Equals(s.SessionKey, name, StringComparison.OrdinalIgnoreCase)); + var matches = byKey is not null + ? [byKey] + : sessions.Where(s => string.Equals(s.Character?.Name, name, StringComparison.OrdinalIgnoreCase)).ToList(); + return matches.Count switch + { + 0 => (null, $"no open character is called {name}"), + > 1 => (null, $"{matches.Count} open characters are called {name}; name one as World.Character"), + _ when !matches[0].IsConnected => (null, $"{matches[0].SessionKey} is not connected"), + _ => (matches[0], null), + }; + } +} diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 8faa309c..79e11b41 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Logging; using SharpMUTerm.Core.Protocols; @@ -26,7 +27,7 @@ public sealed class WorldSession : IAsyncDisposable private readonly Func _sessionFactory; private ILineParser _parser; - private readonly EmojiSubstitutor? _emoji; + private EmojiSubstitutor? _emoji; private ILogSink? _log; private readonly TextSettings? _text; private readonly InputSettings? _input; @@ -82,9 +83,7 @@ public WorldSession( _input = input; _charsetOrder = charsetOrder; _parser = NewParser(world.ContentFormat); - _emoji = world.Emoji.Enabled - ? new EmojiSubstitutor(world.Emoji.Emoticons, world.Emoji.Shortcodes) - : null; + _emoji = NewEmoji(world.Emoji); Scrollback = new ScrollbackBuffer(scrollbackCapacity, CreateSpill(spill ?? new ScrollbackSpillOptions())); Triggers = new TriggerEngine(); @@ -136,6 +135,9 @@ public void ReloadAutomation(IReadOnlyList triggerSets) private IScrollbackSpill? CreateSpill(ScrollbackSpillOptions options) => options.Enabled ? new FileScrollbackSpill(options, SessionKey) : null; + private static EmojiSubstitutor? NewEmoji(EmojiSettings emoji) => + emoji.Enabled ? new EmojiSubstitutor(emoji.Emoticons, emoji.Shortcodes) : null; + private static ILineParser CreateParser(ContentFormat format) => format switch { ContentFormat.Mxp => new MxpParser(), @@ -157,6 +159,11 @@ private ILineParser NewParser(ContentFormat format) if (parser is MxpParser mxp) { mxp.ClientReply += (_, reply) => SendProtocolReply(reply); + mxp.EntityChanged += (_, e) => GameData.SetMxp(e.Name, e.Value); + mxp.Flagged += (_, e) => MxpFlagged?.Invoke(this, e); + mxp.GaugeDeclared += (_, e) => DeclareMeter(e); + mxp.MediaRequested += (_, e) => MediaReceived?.Invoke(this, e); + mxp.Redirected += (_, e) => SpawnLine?.Invoke(this, new SpawnLineEventArgs(e.Frame, ApplyLinks(ApplyEmoji(e.Line)))); } return parser; @@ -219,9 +226,106 @@ async Task Send() /// what it does cover, and is the right call to keep regardless. /// /// - private void OnMxpEnabled(object? sender, EventArgs e) + /// + /// Asks an MSDP server for the variables the meters read. MSDP servers commonly send nothing until a + /// client REPORTs, so without this a meter pointed at MSDP.HEALTH stayed empty for ever. + /// Raised from the read loop; the sends are fire-and-forget because a refused one changes nothing. + /// + private void OnMsdpEnabled(object? sender, EventArgs e) + { + if (sender is not ITelnetSession telnet) + { + return; + } + + foreach (var variable in MsdpReports.Distinct(StringComparer.OrdinalIgnoreCase)) + { + _ = SendMsdpQuietly(telnet, "REPORT", variable); + } + } + + private async Task SendMsdpQuietly(ITelnetSession telnet, string variable, string value) + { + try + { + await telnet.SendMsdpAsync(variable, value).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or ObjectDisposedException) + { + Logger.LogDebug(ex, "MSDP {Variable} {Value} was not sent.", variable, value); + } + } + + /// + /// What ⇥ completes on this connection's command line: names the game sent over GMCP, and the words of + /// the output it shows, in any window. Emptied on connect. + /// + public CompletionIndex Completions { get; } = new(); + + /// + /// Raised on the read loop when the MOO hands this connection a buffer to edit (MCP simpleedit). Only an + /// authenticated MCP message raises it. + /// + public event EventHandler? MooEditReceived; + + /// The MOO's latest status text (MCP dns-com-awns-status), or null when none arrived this connection. + public string? McpStatus { get; private set; } + + /// Raised on the read loop when changes. + public event EventHandler? McpStatusChanged; + + /// + /// Sends an edited buffer back to the MOO. False, and nothing sent, when nothing is connected or the + /// connection that handed the edit out is gone, since a reference means nothing to another one. + /// + public async Task SendMooEditAsync(MooEdit edit, IReadOnlyList lines) + { + if (_telnet is not { IsConnected: true } telnet) + { + return false; + } + + try + { + await telnet.SendMooEditAsync(edit, lines).ConfigureAwait(false); + return true; + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or ObjectDisposedException) + { + Logger.LogDebug(ex, "The edit of {Name} was not sent.", edit.Name); + return false; + } + } + + /// Sends a GMCP message on this connection; refused (false) when nothing is connected. + public async Task SendGmcpAsync(string package, string json) { - if (World.ContentFormat != ContentFormat.Ansi || _parser is MxpParser) + if (_telnet is not { IsConnected: true } telnet) + { + return false; + } + + await telnet.SendGmcpAsync(package, json).ConfigureAwait(false); + return true; + } + + private void OnMxpEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Mxp); + + /// + /// Upgrades to the Pueblo parser when the server switches the connection to Pueblo — the same rule as + /// MXP's, above: only from the automatic default. + /// + private void OnPuebloEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Pueblo); + + /// + /// The one parser swap. Only from and only off the plain ANSI parser, so + /// the first markup a connection negotiates keeps it: a server that offers both MXP and Pueblo is + /// not switched back and forth by the order its handshakes finish in. (The telnet layer also declines to + /// answer a Pueblo hello once MXP is on.) + /// + private void UpgradeParser(ContentFormat format) + { + if (World.ContentFormat != ContentFormat.Ansi || _parser is not AnsiParser) { return; } @@ -231,7 +335,7 @@ private void OnMxpEnabled(object? sender, EventArgs e) ProcessOutputLine(tail); } - _parser = NewParser(ContentFormat.Mxp); + _parser = NewParser(format); } /// @@ -292,17 +396,130 @@ public ILogger Logger public event EventHandler? GmcpReceived; + /// + /// A sound or music command from the server — MCMP's Client.Media.* — raised on the read loop. The + /// client hands it to the connection's media queue; with no audio device nothing listens. + /// + public event EventHandler? MediaReceived; + public event EventHandler? MsdpReceived; + /// + /// The latest GMCP and MSDP data this connection has received. Emptied on connect, because a package a + /// previous connection held says nothing about this one. + /// + public GameDataStore GameData { get; } = new(); + + /// + /// This connection's map, when the client keeps one: set by the client as the session opens, read by the + /// scripting bridge's map.*. Null in a session nothing maps. + /// + public Mapping.SessionMap? Map { get; set; } + + /// + /// The MSDP variables to ask for with REPORT once MSDP is agreed — the ones the status row's meters + /// read. Set by the app; read at that moment, so a change applies from the next connection. + /// + public IReadOnlyList MsdpReports { get; set; } = []; + + /// + /// Whether this client will play what the server sends: set by the app when it has an audio device and game + /// sound is on. True adds Client.Media 1 to the GMCP modules, which is how MCMP asks; read on connect. + /// + public bool PlaysMedia { get; set; } + + /// Whether this connection agreed MSP; written on the read loop, read there too. + private volatile bool _mspActive; + + /// The GMCP modules a connection asks for beyond the defaults: the world's, then MCMP's when it plays media. + public static string[] GmcpModulesFor(WorldDefinition world, bool playsMedia) + { + var modules = world.GmcpModules.ToList(); + if (playsMedia) + { + modules.Add(Media.MediaCommand.GmcpModule); + } + + if (world.GmcpLogin) + { + modules.Add(GmcpLogin.Module); + } + + if (world.DiscordPresence) + { + modules.Add(Presence.GamePresence.GmcpModule); + } + + return modules.ToArray(); + } + public event EventHandler? MsspReceived; + /// + /// Raised on the read loop when an MXP element the server flagged closes — RoomName, RoomDesc, + /// RoomExit, RoomNum and the like — with its text. What the MXP room source reads. + /// + public event EventHandler? MxpFlagged; + + /// The most meters one connection's <GAUGE>s may add. + public const int MaxMxpMeters = 8; + + private readonly Lock _metersGate = new(); + private List _mxpMeters = []; + + /// + /// The status meters this connection's server asked for with MXP <GAUGE> and <STAT>, + /// read from its entities (MXP.hp). Shown after the player's own; emptied on connect. + /// + public IReadOnlyList MxpMeters + { + get + { + lock (_metersGate) + { + return _mxpMeters; + } + } + } + + /// Raised on the read loop when changes. + public event EventHandler? MxpMetersChanged; + + private void DeclareMeter(MxpGauge gauge) + { + var meter = new StatusMeter + { + Label = StatusMeter.Clip(gauge.Caption ?? gauge.Entity), + Value = $"{GameDataStore.MxpPrefix}.{gauge.Entity}", + Max = gauge.Max is null ? null : $"{GameDataStore.MxpPrefix}.{gauge.Max}", + }; + + lock (_metersGate) + { + var kept = _mxpMeters.Where(m => !string.Equals(m.Value, meter.Value, StringComparison.OrdinalIgnoreCase)).ToList(); + if (kept.Count >= MaxMxpMeters) + { + return; + } + + kept.Add(meter); + _mxpMeters = kept; + } + + MxpMetersChanged?.Invoke(this, EventArgs.Empty); + } + public event EventHandler? SpawnLine; /// Raised when the encoding in force changes — see . public event EventHandler? EncodingChanged; - /// Raised for each trigger-requested script callback (consumed by the scripting layer). - public event EventHandler? TriggerScriptRequested; + /// + /// Raised when a trigger, alias, timer or macro names a script callback (consumed by the scripting + /// layer). Raised on whichever thread ran the rule: the read loop for a trigger, the caller for input + /// and keys, the scheduler for a timer. + /// + public event EventHandler? ScriptRequested; public bool IsConnected => _telnet?.IsConnected == true; @@ -371,18 +588,64 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // negotiated again to take it back off ANSI. _parser = NewParser(World.ContentFormat); + // Re-read with the parser, so an F5 change to the world's emoji switches reaches the next connection. + _emoji = NewEmoji(World.Emoji); + SetState(ConnectionState.Connecting, null); PrintSystem($"*** Connecting to {World.Host}:{World.Port}..."); var telnet = _sessionFactory(World.ToConnectionOptions()); _telnet = telnet; telnet.OutputReceived += OnOutputReceived; - telnet.GmcpReceived += (_, e) => GmcpReceived?.Invoke(this, e); - telnet.MsdpReceived += (_, e) => MsdpReceived?.Invoke(this, e); + GameData.Clear(); + lock (_metersGate) + { + _mxpMeters = []; + } + + telnet.GmcpReceived += (_, e) => + { + // Stored before the event, so a handler reading GameData sees the message that raised it. + GameData.SetGmcp(e.Package, e.Json); + Completions.ObserveGmcp(e.Package, e.Json); + OnGmcpLogin(e.Package, e.Json); + if (World.DiscordPresence + && e.Package.StartsWith("External.Discord.", StringComparison.OrdinalIgnoreCase) + && Presence.GamePresence.Apply(GamePresence, e.Package, e.Json) is { } presence + && presence != GamePresence) + { + GamePresence = presence; + GamePresenceChanged?.Invoke(this, EventArgs.Empty); + } + GmcpReceived?.Invoke(this, e); + if (e.Package.StartsWith("Client.Media", StringComparison.Ordinal) + && Media.MediaCommand.FromGmcp(e.Package, e.Json) is { } media) + { + MediaReceived?.Invoke(this, media); + } + }; + telnet.MsdpReceived += (_, e) => + { + GameData.SetMsdp(e.Json); + MsdpReceived?.Invoke(this, e); + }; + telnet.MsdpEnabled += OnMsdpEnabled; telnet.MsspReceived += (_, e) => MsspReceived?.Invoke(this, e); telnet.EncodingChanged += OnEncodingChanged; telnet.Disconnected += OnDisconnected; telnet.MxpEnabled += OnMxpEnabled; + _mspActive = false; + telnet.MspChanged += (_, active) => _mspActive = active; + telnet.PuebloEnabled += OnPuebloEnabled; + McpStatus = null; + GamePresence = null; + Completions.Clear(); + telnet.MooEditReceived += (_, edit) => MooEditReceived?.Invoke(this, edit); + telnet.McpStatusReceived += (_, text) => + { + McpStatus = text; + McpStatusChanged?.Invoke(this, EventArgs.Empty); + }; try { @@ -441,7 +704,7 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) ProcessOutputLine(tail); emitted = true; } - else if (!emitted) + else if (!emitted && _parser is not MxpParser { LineSwallowed: true }) { ProcessOutputLine(StyledLine.Empty); } @@ -461,8 +724,50 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) /// is not threaded through the pipeline below (colour-strip, tab /// expand, triggers, emoji) — it is applied once, at the end, to the line actually delivered. /// + /// + /// Raised on the read loop with the text of a line that began with the world's + /// : a MUSH handing back an attribute or a post to edit. The line + /// is gagged and the text is the client's to put in front of the player, who sends it themselves. + /// + public event EventHandler? EditGrabbed; + + /// + /// Takes a grab line out of the stream. Before the triggers, so a rule cannot send what was grabbed, and + /// matched exactly, case and all, because the prefix is a game's fixed marker and not a pattern. + /// + private bool TryGrab(StyledLine line) + { + if (World.GrabPrefix is not { Length: > 0 } prefix || EditGrabbed is null) + { + return false; + } + + var text = line.Text; + if (!text.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + var rest = text[prefix.Length..]; + EditGrabbed.Invoke(this, rest.StartsWith(' ') ? rest[1..] : rest); + return true; + } + private void ProcessOutputLine(StyledLine line, bool isPrompt = false) { + // An MSP trigger is the server asking for a sound, and is taken out before anything else sees the + // line — but only on a connection that agreed MSP. Anywhere else it is text a player can type. + if (_mspActive && Media.MspTrigger.Parse(line.Text) is { } sound) + { + MediaReceived?.Invoke(this, sound); + return; + } + + if (!isPrompt && TryGrab(line)) + { + return; + } + // Colour is stripped from what the *server* sent, before the triggers run: a highlight rule // and this client's own system/echo lines are not "incoming ANSI colour" and keep theirs. if (_text?.StripIncomingColour == true) @@ -479,7 +784,7 @@ private void ProcessOutputLine(StyledLine line, bool isPrompt = false) foreach (var invocation in result.ScriptInvocations) { - TriggerScriptRequested?.Invoke(this, invocation); + ScriptRequested?.Invoke(this, ScriptRequest.FromMatch(ScriptSource.Trigger, invocation.Callback, invocation.Match)); } // The line as it will be *shown*, computed once and used for both destinations. A spawn window @@ -490,6 +795,10 @@ private void ProcessOutputLine(StyledLine line, bool isPrompt = false) // text and destination disagree is the shape of a phishing link, and this client should not be // in the business of manufacturing one. var shown = ApplyLinks(ApplyEmoji(result.Line)).WithPrompt(isPrompt); + if (result.RouteMain || !result.Suppress || result.SpawnTargets.Count > 0) + { + Completions.ObserveLine(shown.Text); // only what is shown somewhere: a gagged word was never read + } foreach (var target in result.SpawnTargets) { @@ -551,15 +860,42 @@ public async Task SendUserInputAsync(string input, CancellationToken cancellatio if (expansion.Matched) { + if (expansion.Alias?.ScriptCallback is { Length: > 0 } callback && expansion.Match is { } match) + { + ScriptRequested?.Invoke(this, ScriptRequest.FromMatch(ScriptSource.Alias, callback, match)); + } + foreach (var command in expansion.Commands) { - await SendRawAsync(command, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); } return; } - await SendRawAsync(input, cancellationToken).ConfigureAwait(false); + await SendCommandAsync(input, cancellationToken).ConfigureAwait(false); + } + + /// + /// Raised for each command the player sends — typed (after alias expansion), from a macro, or from a + /// speedwalk — after it has gone to the server. What the mapper reads to know which way a room was left. + /// + /// Never the login line or the on-connect commands, which go straight to : + /// the login line carries the password, and nothing that listens here needs to see it. + /// + /// + public event EventHandler? CommandSent; + + /// Sends a command the player chose, and says so on . + public async Task SendCommandAsync(string command, CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + return; + } + + await SendRawAsync(command, cancellationToken).ConfigureAwait(false); + CommandSent?.Invoke(this, command); } /// @@ -596,6 +932,24 @@ private async Task SendLoginAsync(CancellationToken cancellationToken) } var plan = character.Login(); + if (World.GmcpLogin && plan is LoginPlan.WithPassword) + { + // Wait for the server to say whether it takes credentials over GMCP; the connect line is the fallback. + lock (_gmcpLoginGate) + { + _gmcpLoginWaiting = true; + _gmcpLoginFallback?.Dispose(); + _gmcpLoginFallback = Scheduler.After(GmcpLoginWait, () => _ = FinishGmcpLoginAsync(credentials: false), "gmcp-login"); + } + + return; + } + + await SendTextLoginAsync(character, plan, cancellationToken).ConfigureAwait(false); + } + + private async Task SendTextLoginAsync(CharacterDefinition character, LoginPlan plan, CancellationToken cancellationToken) + { if (plan is not LoginPlan.Nothing) { await SendRawAsync(character.ResolveConnectString(), cancellationToken).ConfigureAwait(false); @@ -607,12 +961,103 @@ private async Task SendLoginAsync(CancellationToken cancellationToken) PrintSystem(PasswordUnusedWarning(character.Name)); } + await SendOnConnectAsync(character, cancellationToken).ConfigureAwait(false); + } + + private async Task SendOnConnectAsync(CharacterDefinition character, CancellationToken cancellationToken) + { foreach (var command in SplitCommands(character.OnConnect)) { await SendRawAsync(command, cancellationToken).ConfigureAwait(false); } } + /// + /// What the game last said about itself for Discord (External.Discord), when the world allows it; null + /// before then and after a reconnect. Written on the read loop. + /// + public Presence.GamePresence? GamePresence { get; private set; } + + /// Raised on the read loop when changes. + public event EventHandler? GamePresenceChanged; + + /// How long a GMCP login waits for Char.Login.Default before sending the connect line. + public TimeSpan GmcpLoginWait { get; set; } = TimeSpan.FromSeconds(5); + + private readonly object _gmcpLoginGate = new(); + private bool _gmcpLoginWaiting; + private IDisposable? _gmcpLoginFallback; + + /// + /// The server's half of Char.Login: Default says how it takes a login, Result how one + /// went. Runs on the read loop. Nothing here writes the password anywhere but the wire. + /// + private void OnGmcpLogin(string package, string json) + { + if (string.Equals(package, GmcpLogin.DefaultPackage, StringComparison.OrdinalIgnoreCase)) + { + _ = FinishGmcpLoginAsync(credentials: GmcpLogin.OffersCredentials(json)); + } + else if (string.Equals(package, GmcpLogin.ResultPackage, StringComparison.OrdinalIgnoreCase) + && GmcpLogin.ReadResult(json) is { } result + && Character is { } character) + { + PrintSystem(result.Success + ? $"*** Logged in as {character.Name} over GMCP." + : $"*** The GMCP login for {character.Name} was refused{(string.IsNullOrWhiteSpace(result.Message) ? "." : ": " + result.Message)}"); + } + } + + /// + /// Ends the wait, once: sends the credentials when the server takes them, the connect line when it does not + /// or never said, then the character's on-connect commands. + /// + private async Task FinishGmcpLoginAsync(bool credentials) + { + lock (_gmcpLoginGate) + { + if (!_gmcpLoginWaiting) + { + return; + } + + _gmcpLoginWaiting = false; + _gmcpLoginFallback?.Dispose(); + _gmcpLoginFallback = null; + } + + if (Character is not { } character) + { + return; + } + + try + { + if (credentials && await SendGmcpAsync(GmcpLogin.CredentialsPackage, GmcpLogin.Credentials(character.Name, character.Password ?? string.Empty)).ConfigureAwait(false)) + { + PrintSystem($"*** Sent the login for {character.Name} over GMCP."); + await SendOnConnectAsync(character, CancellationToken.None).ConfigureAwait(false); + return; + } + + await SendTextLoginAsync(character, character.Login(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + Logger.LogDebug(ex, "The login for {Character} was not sent.", character.Name); + } + } + + private void CancelGmcpLogin() + { + lock (_gmcpLoginGate) + { + _gmcpLoginWaiting = false; + _gmcpLoginFallback?.Dispose(); + _gmcpLoginFallback = null; + } + } + /// /// What the session prints when it has logged itself in. It names the character and not the line, /// because the line is the one string in this client worth keeping out of the transcript — and @@ -658,12 +1103,20 @@ private void StartTimers() var interval = TimeSpan.FromSeconds(definition.IntervalSeconds); void Fire() { - if (!definition.Enabled || string.IsNullOrWhiteSpace(definition.Command)) + if (!definition.Enabled) { return; } - _ = SendRawAsync(definition.Command); + if (definition.ScriptCallback is { Length: > 0 } callback) + { + ScriptRequested?.Invoke(this, ScriptRequest.Bare(ScriptSource.Timer, callback)); + } + + if (!string.IsNullOrWhiteSpace(definition.Command)) + { + _ = SendRawAsync(definition.Command); + } } _timerHandles.Add(definition.OneShot @@ -713,12 +1166,22 @@ public async Task SendRawAsync(string command, CancellationToken cancellationTok public async Task HandleKeyAsync(string keyDescriptor, CancellationToken cancellationToken = default) { var macro = Macros.Resolve(keyDescriptor); - if (macro is null || string.IsNullOrEmpty(macro.Command)) + if (macro is null) { return null; } - await SendRawAsync(macro.Command, cancellationToken).ConfigureAwait(false); + if (macro.ScriptCallback is { Length: > 0 } callback) + { + ScriptRequested?.Invoke(this, ScriptRequest.Bare(ScriptSource.Macro, callback)); + } + + if (string.IsNullOrEmpty(macro.Command)) + { + return null; + } + + await SendCommandAsync(macro.Command, cancellationToken).ConfigureAwait(false); return macro.Command; } @@ -763,6 +1226,7 @@ private void OnEncodingChanged(object? sender, SessionEncodingEventArgs e) private void OnDisconnected(object? sender, SessionDisconnectedEventArgs e) { + CancelGmcpLogin(); StopTimers(); ClearPrompt(); SetState(e.IsClean ? ConnectionState.Disconnected : ConnectionState.Faulted, e.Error); @@ -799,10 +1263,44 @@ public async Task DisconnectAsync() { if (_telnet is not null) { + await SendLeavingAsync().ConfigureAwait(false); await _telnet.DisconnectAsync().ConfigureAwait(false); } } + /// + /// Sends the character's commands (QUIT, say) + /// before the client closes a connection itself — on Disconnect, Reconnect and quitting the client. A + /// connection the server dropped gets none: there is nothing left to send them on. + /// + /// Bounded by and never throws, because it runs on the way out of the client + /// and a hung socket must not hold the exit. + /// + /// + private async Task SendLeavingAsync() + { + if (!IsConnected || Character is not { } character) + { + return; + } + + using var timeout = new CancellationTokenSource(LeavingTimeout); + try + { + foreach (var command in SplitCommands(character.OnDisconnect)) + { + await SendRawAsync(command, timeout.Token).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException) + { + Logger.LogDebug(ex, "{World}: the on-disconnect commands were not all sent", World.Name); + } + } + + /// How long the on-disconnect commands may take before the connection is closed anyway. + internal static readonly TimeSpan LeavingTimeout = TimeSpan.FromSeconds(1); + private void SetState(ConnectionState state, Exception? error) { State = state; @@ -849,6 +1347,8 @@ private ITelnetSession DefaultSessionFactory(ConnectionOptions options) Logger, options: new TelnetSessionOptions { + GmcpModules = GmcpModulesFor(World, PlaysMedia), + AnnouncePueblo = World.ContentFormat is ContentFormat.Ansi or ContentFormat.Pueblo, CharsetOrder = order, EncodingOverride = encodingOverride, KeepaliveInterval = TelnetSessionOptions.ResolveKeepalive(World.KeepaliveSeconds), @@ -863,6 +1363,7 @@ public async ValueTask DisposeAsync() Scrollback.Dispose(); // deletes this session's scrollback spill cache; the log above is kept if (_telnet is not null) { + await SendLeavingAsync().ConfigureAwait(false); await _telnet.DisposeAsync().ConfigureAwait(false); _telnet = null; } diff --git a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj index a4c8ebdd..16201207 100644 --- a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj +++ b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj @@ -8,6 +8,7 @@ + diff --git a/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs b/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs new file mode 100644 index 00000000..0e0d272b --- /dev/null +++ b/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs @@ -0,0 +1,100 @@ +using System.Globalization; +using System.Reflection; +using TelnetNegotiationCore.Gmcp; + +namespace SharpMUTerm.Core.Telnet; + +/// +/// What this client says once GMCP is agreed: Core.Hello with its name and version, then +/// Core.Supports.Set with the modules it wants. Many servers send nothing until the second arrives — +/// IRE games and every server built on their convention among them — and this client sent neither, so on +/// those servers GMCP was negotiated and then silent. +/// +/// +/// Nothing here is sent unless the server offered GMCP (IAC WILL GMCP) and the library agreed to it: +/// the handshake rides OnGMCPNegotiated, so the rule that this client asks no server to enable an +/// option it has not offered still holds. +/// +public static class GmcpHandshake +{ + /// The name this client introduces itself by. + public const string ClientName = "SharpMUTerm"; + + /// + /// The modules asked for on every world, Mudlet's common set. A world adds to these on F5; it cannot + /// remove them, because a server ignores a module it does not have. + /// + public static IReadOnlyList DefaultModules { get; } = + ["Char 1", "Char.Skills 1", "Char.Items 1", "Room 1", "Comm.Channel 1"]; + + /// + /// This build's version, as MinVer stamped it, without the commit suffix a server has no use for. + /// + public static string ClientVersion { get; } = ReadVersion(); + + /// + /// The modules for one world: the defaults, then the world's own, each parsed from Name Version + /// (the version defaults to 1). A later entry for a module already listed replaces its version; an entry + /// that does not parse is dropped. + /// + public static IReadOnlyList Modules(IEnumerable? extra = null) + { + var modules = new List(); + foreach (var spec in DefaultModules.Concat(extra ?? [])) + { + if (Parse(spec) is not { } module) + { + continue; + } + + var at = modules.FindIndex(m => string.Equals(m.Name, module.Name, StringComparison.OrdinalIgnoreCase)); + if (at >= 0) + { + modules[at] = module; + } + else + { + modules.Add(module); + } + } + + return modules; + } + + /// Parses Char.Vitals 1 or Char.Vitals; null for anything else. + public static GmcpModule? Parse(string? spec) + { + var parts = (spec ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length is 0 or > 2 || !IsName(parts[0])) + { + return null; + } + + var version = 1; + if (parts.Length == 2 + && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out version) || version < 1)) + { + return null; + } + + return new GmcpModule(parts[0], version); + } + + private static bool IsName(string name) => + name.Length is > 0 and <= 64 + && name[0] != '.' && name[^1] != '.' + && name.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-'); + + private static string ReadVersion() + { + var info = typeof(GmcpHandshake).Assembly + .GetCustomAttribute()?.InformationalVersion; + if (string.IsNullOrEmpty(info)) + { + return "0.0.0"; + } + + var plus = info.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? info : info[..plus]; + } +} diff --git a/src/SharpMUTerm.Core/Telnet/GmcpLogin.cs b/src/SharpMUTerm.Core/Telnet/GmcpLogin.cs new file mode 100644 index 00000000..30b6e605 --- /dev/null +++ b/src/SharpMUTerm.Core/Telnet/GmcpLogin.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Core.Telnet; + +/// +/// GMCP's Char.Login, as Mudlet and the servers written for it speak it. The server says +/// Char.Login.Default {"type": ["password-credentials"]}, the client answers +/// Char.Login.Credentials {"account": …, "password": …}, and the server reports +/// Char.Login.Result {"success": …, "message": …}. +/// +/// +/// The password is built into the message here and goes to SendGmcpAsync and nowhere else: not to the +/// transcript, the scrollback, the game-data view or the diagnostics log, which is the rule the connect line is +/// already held to. +/// +public static class GmcpLogin +{ + /// What a client lists in Core.Supports.Set to say it can log in this way. + public const string Module = "Char.Login 1"; + + public const string DefaultPackage = "Char.Login.Default"; + public const string CredentialsPackage = "Char.Login.Credentials"; + public const string ResultPackage = "Char.Login.Result"; + + /// The login type this client implements. + public const string PasswordCredentials = "password-credentials"; + + /// + /// Whether a Char.Login.Default body offers . The type may be a + /// list or a single string; anything else offers nothing. + /// + public static bool OffersCredentials(string json) + { + try + { + var type = JsonNode.Parse(json)?["type"]; + return type switch + { + JsonArray list => list.Any(t => IsCredentials(t)), + JsonValue one => IsCredentials(one), + _ => false, + }; + } + catch (JsonException) + { + return false; + } + } + + /// The Char.Login.Credentials body for a character. + public static string Credentials(string account, string password) => + new JsonObject { ["account"] = account, ["password"] = password }.ToJsonString(); + + /// A Char.Login.Result body, or null when it is not one. + public static LoginResult? ReadResult(string json) + { + try + { + if (JsonNode.Parse(json) is not JsonObject body || body["success"] is not JsonValue success) + { + return null; + } + + var ok = success.TryGetValue(out bool flag) ? flag + : success.TryGetValue(out string? text) && string.Equals(text, "true", StringComparison.OrdinalIgnoreCase); + var message = body["message"] is JsonValue m && m.TryGetValue(out string? said) ? said : null; + return new LoginResult(ok, message is null ? null : Bounded(message)); + } + catch (JsonException) + { + return null; + } + } + + private static bool IsCredentials(JsonNode? node) => + node is JsonValue value + && value.TryGetValue(out string? type) + && string.Equals(type, PasswordCredentials, StringComparison.OrdinalIgnoreCase); + + /// A server's message is printed in the window, so it is kept to one short line. + private static string Bounded(string message) + { + var line = string.Join(' ', message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)).Trim(); + return line.Length <= 200 ? line : line[..199] + "…"; + } +} + +/// How the server says a GMCP login went. +public sealed record LoginResult(bool Success, string? Message); diff --git a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs index 405f67cc..5f0c4f46 100644 --- a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs @@ -39,6 +39,50 @@ public interface ITelnetSession : IAsyncDisposable /// event EventHandler? MxpEnabled; + /// + /// Raised when MSP (option 90) is agreed (true) or withdrawn (false). While it is agreed, a + /// line that is an MSP trigger is a sound request rather than text. A session that cannot negotiate MSP + /// never raises it, which is what the default does. + /// + event EventHandler? MspChanged + { + add { } + remove { } + } + + /// + /// Raised once the server has switched this connection to Pueblo (its start sequence, after our + /// PUEBLOCLIENT). The of Pueblo. + /// + event EventHandler? PuebloEnabled; + + /// + /// Raised once the client has agreed to the server's MSDP, so a consumer can ask it to REPORT the + /// variables it wants. MSDP servers commonly send nothing until asked. + /// + event EventHandler? MsdpEnabled; + + /// + /// Raised when a MOO hands this client a buffer to edit (MCP dns-org-mud-moo-simpleedit). Only an + /// authenticated MCP message raises it, so a player cannot. + /// + event EventHandler? MooEditReceived + { + add { } + remove { } + } + + /// Raised with a MOO's status text (MCP dns-com-awns-status). + event EventHandler? McpStatusReceived + { + add { } + remove { } + } + + /// Sends an edited buffer back to the MOO that handed it out. Throws when MCP editing is not agreed. + ValueTask SendMooEditAsync(MooEdit edit, IReadOnlyList lines) => + ValueTask.FromException(new InvalidOperationException("This session cannot send MOO edits.")); + /// Connects the transport and begins negotiation and the receive loop. Task ConnectAsync(CancellationToken cancellationToken = default); @@ -51,6 +95,9 @@ public interface ITelnetSession : IAsyncDisposable /// Sends a GMCP message. ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default); + /// Sends one MSDP variable and value, e.g. REPORT HEALTH. + ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default); + /// /// Reports the terminal window size via NAWS. Before the server enables NAWS, retains the latest /// size and reports it automatically when negotiation completes; unchanged sizes are not resent. diff --git a/src/SharpMUTerm.Core/Telnet/MooEditing.cs b/src/SharpMUTerm.Core/Telnet/MooEditing.cs new file mode 100644 index 00000000..8447a92d --- /dev/null +++ b/src/SharpMUTerm.Core/Telnet/MooEditing.cs @@ -0,0 +1,64 @@ +using TelnetNegotiationCore.Models; + +namespace SharpMUTerm.Core.Telnet; + +/// +/// A buffer a MOO handed this client to edit over MCP (dns-org-mud-moo-simpleedit), as the server +/// sent it. Saving it sends the lines back under the same and . +/// +/// The server's handle for what is being edited, such as #98:look. Opaque. +/// What to call it, such as Test:look; the reference when the server named none. +/// moo-code, string-list or string. +/// The content, a line each. +public sealed record MooEdit(string Reference, string Name, string Type, IReadOnlyList Lines); + +/// The MCP packages this client speaks, and their message names. +public static class MooEditing +{ + /// Every package here is 1.0. + public static readonly McpVersion Version = new(1, 0); + + /// Editing a verb, a property or a note in the client. + public const string SimpleEdit = "dns-org-mud-moo-simpleedit"; + + /// Server to client: a buffer to edit. + public const string SimpleEditContent = SimpleEdit + "-content"; + + /// Client to server: the edited buffer. + public const string SimpleEditSet = SimpleEdit + "-set"; + + /// The client's name and version, and its screen size. + public const string VmooClient = "dns-com-vmoo-client"; + + /// Client to server: name and version. + public const string VmooClientInfo = VmooClient + "-info"; + + /// Client to server: columns and rows. + public const string VmooClientScreenSize = VmooClient + "-screensize"; + + /// Server to client: one line of status text. The package and its one message share the name. + public const string AwnsStatus = "dns-com-awns-status"; + + /// The simpleedit type for a list of lines, and what an edit with no type is read as. + public const string StringList = "string-list"; + + /// + /// The lines of an edited buffer, as typed: CRLF, LF and a lone CR alike, and nothing trimmed but one + /// newline at the very end, which is where the editor's caret was left. An empty buffer is no lines. + /// + public static IReadOnlyList LinesOf(string? body) + { + if (string.IsNullOrEmpty(body)) + { + return []; + } + + var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n').ToList(); + if (lines.Count > 1 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + return lines; + } +} diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 6a449115..7975b159 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -5,6 +5,7 @@ using SharpMUTerm.Core.Telnet.Mssp; using SharpMUTerm.Core.Transport; using TelnetNegotiationCore.Builders; +using TelnetNegotiationCore.Gmcp; using TelnetNegotiationCore.Interpreters; using TelnetNegotiationCore.Models; using TelnetNegotiationCore.Protocols; @@ -64,14 +65,14 @@ public sealed class TelnetSessionOptions /// What the interactive client announces: its own name, the terminal class it renders as, and an /// MTTS bit vector claiming only what it actually does. /// - /// 2333 = ANSI (1) + UTF-8 (4) + 256 colours (8) + mouse tracking (16) + true colour (256) - /// + SSL (2048). Every one of those is implemented: SGR through AnsiParser, UTF-8 through - /// CHARSET negotiation, mouse through the pane drag/wheel/click routing, TLS through - /// TcpTransport. + /// 2845 = ANSI (1) + UTF-8 (4) + 256 colours (8) + mouse tracking (16) + true colour (256) + /// + MNES (512) + SSL (2048). Every one of those is implemented: SGR through AnsiParser, UTF-8 + /// through CHARSET negotiation, mouse through the pane drag/wheel/click routing, MNES through + /// on NEW-ENVIRON, TLS through TcpTransport. /// /// /// Not 3853, which the library claimed on our behalf and which is wrong in both directions: - /// it asserts MNES (512) — NEW-ENVIRON is not registered by this client at all — and + /// it asserted MNES (512) before this client registered NEW-ENVIRON, and /// MSLP (1024), which is not implemented anywhere in the stack, while omitting the mouse /// tracking that is. A terminal-type string is a claim a server acts on; MTTS exists so a server /// can decide what to send us, and over-claiming makes it send what we cannot render. @@ -83,7 +84,35 @@ public sealed class TelnetSessionOptions /// /// public static IReadOnlyList SharpMUTermTerminalTypes { get; } = - ["SHARPMUTERM", "XTERM", "MTTS 2333"]; + ["SHARPMUTERM", "XTERM", "MTTS 2845"]; + + /// + /// What this client says over NEW-ENVIRON when a server asks (MNES): its name, version, terminal type and + /// the same MTTS bit vector TTYPE ends on, so the two channels cannot disagree. Nothing about the machine: + /// no IPADDRESS, no USER. No CHARSET either, because the answer is fixed when the + /// connection opens and CHARSET may settle on something else afterwards, and the library has no way yet + /// to send the INFO that would correct it. + /// + public static IReadOnlyDictionary MnesIdentity(IReadOnlyList? terminalTypes) + { + var types = terminalTypes is { Count: > 0 } ? terminalTypes : SharpMUTermTerminalTypes; + var variables = new Dictionary + { + [MnesVariables.ClientName] = GmcpHandshake.ClientName, + [MnesVariables.ClientVersion] = GmcpHandshake.ClientVersion, + }; + if (types.Count > 1 && !types[1].StartsWith("MTTS ", StringComparison.Ordinal)) + { + variables[MnesVariables.TerminalType] = types[1]; + } + + if (types.FirstOrDefault(t => t.StartsWith("MTTS ", StringComparison.Ordinal)) is { } mtts) + { + variables[MnesVariables.Mtts] = mtts["MTTS ".Length..]; + } + + return variables; + } /// /// How long the connection may sit silent before a keepalive goes out, or null for none — a @@ -97,6 +126,30 @@ public sealed class TelnetSessionOptions /// public TimeSpan? KeepaliveInterval { get; init; } + /// + /// Whether to send Core.Hello and Core.Supports.Set once the server's GMCP is agreed + /// (). On by default: it is what makes a server that waits to be told which + /// modules a client wants send anything at all. + /// + public bool IntroduceOverGmcp { get; init; } = true; + + /// + /// Modules asked for beyond , each Name Version — a + /// world's . + /// + public IReadOnlyList GmcpModules { get; init; } = []; + + /// + /// Whether to answer a server's Pueblo hello (This world is Pueblo 1.10 Enhanced.) with + /// PUEBLOCLIENT. Pueblo has no telnet option: the answer is a line of text, so it is sent only to a + /// server that has announced Pueblo, and only when nothing else (MXP) already owns the markup. The world's + /// content format decides it — see WorldSession. + /// + public bool AnnouncePueblo { get; init; } = true; + + /// The version this client announces as a Pueblo client; PennMUSH reads only the word. + public const string PuebloVersion = "2.50"; + /// /// Turns a world's configured keepalive seconds into an interval the telnet library will accept, /// or null when there should be none. @@ -233,18 +286,10 @@ public static (Encoding[] Order, Encoding? Override) ResolveWorldEncoding( /// public sealed class TelnetSession : ITelnetSession { - // TelnetInterpreter.CallbackOnByteAsync is init-only and not exposed by the builder, so - // we assign it reflectively after building. This is the one seam where we reach past the - // library's public surface; a first-class OnByte builder hook is a candidate upstream PR. - private static readonly PropertyInfo ByteCallbackProperty = - typeof(TelnetInterpreter).GetProperty(nameof(TelnetInterpreter.CallbackOnByteAsync)) - ?? throw new InvalidOperationException("TelnetInterpreter.CallbackOnByteAsync not found."); - // TelnetInterpreter.CurrentEncoding has an internal setter, which CharsetProtocol itself writes // through reflection once negotiation settles. We seed it the same way at connect time, for two // reasons. It *defaults to Encoding.UTF8* (TelnetStandardInterpreter.cs:46), and that default is - // not inert: it is handed to CallbackOnByteAsync once per byte and to CallbackOnSubmitAsync once - // per submitted line, and used to decode GMCP, MSDP and MSSP payloads and to encode everything we + // not inert: it is handed to CallbackOnSubmitAsync once per submitted line, and used to decode GMCP, MSDP and MSSP payloads and to encode everything we // send — so before negotiation, a // world pinned to a non-UTF-8 override, or a server that never implements RFC 2066 at all, would // decode against UTF-8 regardless of what this session decided. And because the seed is an @@ -259,8 +304,6 @@ public sealed class TelnetSession : ITelnetSession private readonly ILogger _logger; private readonly TelnetSessionOptions _options; - private readonly List _pending = new(); - /// /// The instance seeded into the interpreter at connect time, held so negotiation can be told apart /// from the seed by reference. Null when nothing has been seeded (before connect, or if the @@ -357,6 +400,8 @@ private Encoding? Negotiated public event EventHandler? MsspReceived; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? MsdpEnabled; + public event EventHandler? PuebloEnabled; public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -368,11 +413,12 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Transport must be open before building: the interpreter emits initial negotiation // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport. await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + _msdpAgreed = false; + _mxpOn = false; try { _interpreter = await BuildInterpreterAsync().ConfigureAwait(false); - ByteCallbackProperty.SetValue(_interpreter, new Func(OnByteAsync)); SeedInterpreterEncoding(_interpreter); ApplyTerminalTypes(_interpreter); WireCharsetCallback(_interpreter); @@ -407,16 +453,28 @@ private Task BuildInterpreterAsync() // Explicit defaults let NAWS observe negotiation completion, so an early pane report // is retained and sent on DO NAWS rather than silently discarded by the base plugin. .AddPlugin(new WindowSizeProtocol().OnNAWS(static (_, _) => ValueTask.CompletedTask)) - .AddPlugin().OnGMCPMessage(OnGmcpAsync) + .AddPlugin().OnGMCPMessage(OnGmcpAsync).OnGMCPNegotiated(OnGmcpNegotiatedAsync) .AddPlugin().OnMSDPMessage(OnMsdpAsync) .AddPlugin().OnMSSP(OnMsspAsync).WithMSSPConfig(static () => new MSSPConfig()) .AddPlugin() + // Answers a server's DO NEW-ENVIRON; offers nothing on its own (the unsolicited-negotiation rule). + .AddPlugin().WithClientEnvironmentVariables(TelnetSessionOptions.MnesIdentity(_options.TerminalTypes)) .AddPlugin().WithCharsetOrder(_options.CharsetOrder) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) - .AddPlugin().OnCompressionEnabled(OnCompressionAsync); + .AddPlugin().OnPuebloOffered(OnPuebloOfferedAsync).OnPuebloEnabled(OnPuebloEnabledAsync) + .AddPlugin().OnCompressionEnabled(OnCompressionAsync) + // MCP is in-band and the server opens it (#$#mcp version), so registering it sends nothing to a + // server that never offers it. The library takes every MCP line out of the stream. + .AddPlugin() + .SupportsMcpPackage(MooEditing.SimpleEdit, MooEditing.Version, MooEditing.Version) + .SupportsMcpPackage(MooEditing.VmooClient, MooEditing.Version, MooEditing.Version) + .SupportsMcpPackage(MooEditing.AwnsStatus, MooEditing.Version, MooEditing.Version) + .OnMcpMessage(MooEditing.SimpleEditContent, OnMooEditAsync) + .OnMcpMessage(MooEditing.AwnsStatus, OnMcpStatusAsync) + .OnMcpNegotiationComplete(OnMcpAgreedAsync); // An idle keepalive, when the world asks for one: IAC NOP after the configured silence, so a // NAT or load balancer doesn't evict a connection that is merely quiet. It is filler traffic, @@ -532,15 +590,57 @@ private void ReportEncodingIfChanged() EncodingChanged?.Invoke(this, new SessionEncodingEventArgs(current)); } - private ValueTask WriteToTransportAsync(ReadOnlyMemory data) => - _transport.SendAsync(data); + /// + /// The library's negotiation output, written as-is. It is also where this session learns MSDP was + /// agreed: MSDP has no negotiated callback, and the library's IAC DO MSDP answer to a server's + /// WILL is the moment it is. Only negotiation passes through here, never data, so the three bytes + /// cannot be a literal in someone's text. + /// + private ValueTask WriteToTransportAsync(ReadOnlyMemory data) + { + if (!_msdpAgreed && Contains(data.Span, [Iac, DoCommand, MsdpOption])) + { + _msdpAgreed = true; + _logger.LogInformation("MSDP negotiated."); + var transportWrite = _transport.SendAsync(data); + MsdpEnabled?.Invoke(this, EventArgs.Empty); + return transportWrite; + } + + return _transport.SendAsync(data); + } + + private const byte Iac = 255; + private const byte DoCommand = 253; + private const byte MsdpOption = 69; + + /// Whether this connection has agreed MSDP; reset on every connect. + private volatile bool _msdpAgreed; - private ValueTask OnByteAsync(byte value, Encoding encoding) + private static bool Contains(ReadOnlySpan haystack, ReadOnlySpan needle) => + haystack.IndexOf(needle) >= 0; + + /// + /// Introduces the client once GMCP is agreed — Mudlet's order, Core.Hello first. A failure here + /// is logged and swallowed: a server that drops the handshake still has a working connection. + /// + private async ValueTask OnGmcpNegotiatedAsync(bool agreed) { - // The encoding the interpreter offers here is deliberately ignored: it is the negotiated one, - // which CurrentEncoding already consults and an override deliberately outranks. - _pending.Add(value); - return ValueTask.CompletedTask; + if (!agreed || !_options.IntroduceOverGmcp || _interpreter is not { } interpreter) + { + return; + } + + try + { + var gmcp = new GmcpClientSession((package, data) => interpreter.SendGMCPCommand(package, data)); + await gmcp.HelloAsync(GmcpHandshake.ClientName, GmcpHandshake.ClientVersion).ConfigureAwait(false); + await gmcp.SetSupportsAsync(GmcpHandshake.Modules(_options.GmcpModules)).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not send the GMCP handshake."); + } } private ValueTask OnSubmitAsync(byte[] bytes, Encoding? encoding, TelnetInterpreter interpreter) @@ -549,28 +649,26 @@ private ValueTask OnSubmitAsync(byte[] bytes, Encoding? encoding, TelnetInterpre // decode them with what this session has decided is in force — the interpreter's own encoding // is the negotiated one, and an override outranks that. var text = CurrentEncoding.Encoding.GetString(bytes); - _pending.Clear(); Emit(text, isPrompt: false); return ValueTask.CompletedTask; } + /// + /// A GA, EOR or inferred boundary: the partial line the interpreter just handed over is the prompt. + /// + /// Read from LastPromptBytes, which the library sets on the read loop before raising this, rather + /// than from a copy of every byte. A copy also held the lines the library consumes — an MCP message, a + /// Pueblo hello — since nothing marks where those end, and a boundary straight after one printed it. + /// + /// private ValueTask OnPromptAsync() { - // GA/EOR boundary: flush whatever data has accumulated as a prompt. - FlushPending(isPrompt: true); - return ValueTask.CompletedTask; - } - - private void FlushPending(bool isPrompt) - { - if (_pending.Count == 0) + if (_interpreter?.LastPromptBytes is { Length: > 0 } bytes) { - return; + Emit(CurrentEncoding.Encoding.GetString(bytes.Span), isPrompt: true); } - var text = CurrentEncoding.Encoding.GetString(_pending.ToArray()); - _pending.Clear(); - Emit(text, isPrompt); + return ValueTask.CompletedTask; } private void Emit(string text, bool isPrompt) => @@ -608,8 +706,129 @@ private ValueTask OnCompressionAsync(int version, bool enabled) return ValueTask.CompletedTask; } + /// + /// The server's Pueblo hello: answered with PUEBLOCLIENT unless the world says otherwise or MXP is + /// already in use. The hello is consumed by the library. + /// + private async ValueTask OnPuebloOfferedAsync() + { + if (!_options.AnnouncePueblo || _mxpOn || _interpreter?.PluginManager?.GetPlugin() is not { } pueblo) + { + _logger.LogInformation("The server offered Pueblo; not announcing (content format or MXP)."); + return; + } + + try + { + await pueblo.AnnounceAsync(TelnetSessionOptions.PuebloVersion).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not answer the server's Pueblo hello."); + } + } + + /// The server's Pueblo start sequence, consumed by the library like the hello. + private ValueTask OnPuebloEnabledAsync(PuebloClient client) + { + _logger.LogInformation("Pueblo negotiated."); + PuebloEnabled?.Invoke(this, EventArgs.Empty); + return ValueTask.CompletedTask; + } + + private volatile bool _mxpOn; + + // ----- MCP ---------------------------------------------------------------------- + + private MudClientProtocol? Mcp => _interpreter?.PluginManager?.GetPlugin(); + + /// The latest window size reported, held so a MOO that agrees screensize after NAWS still hears it. + private (int Width, int Height)? _screenSize; + + /// + public event EventHandler? MooEditReceived; + + /// + public event EventHandler? McpStatusReceived; + + private bool Agreed(string package) => Mcp?.Agreed.ContainsKey(package) == true; + + /// + /// dns-org-mud-moo-simpleedit-content. The library has already checked the key, so this is the + /// server's and not a line a player typed. One without a reference cannot be saved and is dropped. + /// + private ValueTask OnMooEditAsync(McpMessage message) + { + if (message.Value("reference") is not { Length: > 0 } reference) + { + _logger.LogDebug("Dropping a simpleedit message with no reference."); + return ValueTask.CompletedTask; + } + + var edit = new MooEdit( + reference, + message.Value("name") is { Length: > 0 } name ? name : reference, + message.Value("type") is { Length: > 0 } type ? type : MooEditing.StringList, + message.Lines("content")); + MooEditReceived?.Invoke(this, edit); + return ValueTask.CompletedTask; + } + + private ValueTask OnMcpStatusAsync(McpMessage message) + { + McpStatusReceived?.Invoke(this, message.Value("text") ?? string.Empty); + return ValueTask.CompletedTask; + } + + /// Once the packages are settled: tell a MOO that speaks vmoo-client who this is and how wide. + private async ValueTask OnMcpAgreedAsync(IReadOnlyDictionary agreed) + { + if (!agreed.ContainsKey(MooEditing.VmooClient) || Mcp is not { } mcp) + { + return; + } + + try + { + await mcp.SendAsync( + MooEditing.VmooClientInfo, + ("name", GmcpHandshake.ClientName), + ("text-version", GmcpHandshake.ClientVersion), + ("internal-version", "0")).ConfigureAwait(false); + if (_screenSize is { } size) + { + await SendScreenSizeAsync(mcp, size).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not send the MCP client information."); + } + } + + private static ValueTask SendScreenSizeAsync(MudClientProtocol mcp, (int Width, int Height) size) => + mcp.SendAsync( + MooEditing.VmooClientScreenSize, + ("cols", size.Width.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ("rows", size.Height.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + /// + public async ValueTask SendMooEditAsync(MooEdit edit, IReadOnlyList lines) + { + if (Mcp is not { } mcp || !Agreed(MooEditing.SimpleEdit)) + { + throw new InvalidOperationException("The server has not agreed MOO editing on this connection."); + } + + await mcp.SendMultilineAsync( + MooEditing.SimpleEditSet, + [("reference", edit.Reference), ("type", edit.Type)], + ("content", lines.ToArray())).ConfigureAwait(false); + } + private ValueTask OnMxpEnabledAsync() { + _mxpOn = true; _logger.LogInformation("MXP negotiated."); MxpEnabled?.Invoke(this, EventArgs.Empty); return ValueTask.CompletedTask; @@ -689,12 +908,29 @@ public ValueTask SendGmcpAsync(string package, string json, CancellationToken ca return interpreter.SendGMCPCommand(package, json ?? string.Empty); } + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); + return interpreter.SendMSDPCommand(variable, value ?? string.Empty); + } + public ValueTask SetWindowSizeAsync(int width, int height) { var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); var protocol = interpreter.PluginManager?.GetPlugin() as WindowSizeProtocol ?? throw new InvalidOperationException("Window size protocol is not configured."); - return protocol.ReportAsync(width, height); + var changed = _screenSize != (width, height); + _screenSize = (width, height); + return changed && Agreed(MooEditing.VmooClient) && Mcp is { } mcp + ? ReportBothAsync(protocol, mcp, width, height) + : protocol.ReportAsync(width, height); + } + + private static async ValueTask ReportBothAsync(WindowSizeProtocol protocol, MudClientProtocol mcp, int width, int height) + { + await protocol.ReportAsync(width, height).ConfigureAwait(false); + await SendScreenSizeAsync(mcp, (width, height)).ConfigureAwait(false); } public async Task DisconnectAsync() diff --git a/src/SharpMUTerm.Core/Text/SpanInteraction.cs b/src/SharpMUTerm.Core/Text/SpanInteraction.cs index 3f59b7f6..16e056f7 100644 --- a/src/SharpMUTerm.Core/Text/SpanInteraction.cs +++ b/src/SharpMUTerm.Core/Text/SpanInteraction.cs @@ -29,6 +29,13 @@ public sealed record SpanInteraction( /// public InlineImageRequest? Image { get; init; } + /// + /// The commands an MXP <SEND HREF="a|b|c"> offers, in order, with their labels; null for a + /// span with one command. is always the first, so a click sends that and a menu + /// offers the rest. Not persisted by : a restored line keeps its first command. + /// + public IReadOnlyList? Choices { get; init; } + /// A clickable command. puts it on the input line instead of sending. public static SpanInteraction Command(string command, string? hint = null, bool promptOnly = false) => new(InteractionKind.SendCommand, command, hint, promptOnly); @@ -37,3 +44,6 @@ public static SpanInteraction Command(string command, string? hint = null, bool public static SpanInteraction Link(string url, string? hint = null) => new(InteractionKind.Hyperlink, url, hint); } + +/// One entry of an MXP <SEND> menu: what it is called and what it sends. +public sealed record SendChoice(string Label, string Command); diff --git a/src/SharpMUTerm.Core/Text/SpellChecker.cs b/src/SharpMUTerm.Core/Text/SpellChecker.cs new file mode 100644 index 00000000..65de70bc --- /dev/null +++ b/src/SharpMUTerm.Core/Text/SpellChecker.cs @@ -0,0 +1,326 @@ +using WeCantSpell.Hunspell; + +namespace SharpMUTerm.Core.Text; + +/// A word the checker does not know, as a range of one line. +public readonly record struct Misspelling(int Start, int Length); + +/// +/// Spelling for the F1 composer: a Hunspell dictionary (the .aff/.dic pair LibreOffice and every +/// Linux distribution ship), plus the words a player has added. +/// +/// +/// +/// No dictionary is bundled. One is found in the folders is given — the client's own +/// dictionaries/ folder first, then the system's — and when there is none the composer says so rather +/// than marking nothing and looking as if every word were right. +/// +/// +/// What is not a word to check. A MUSH post is half markup, so a run that follows a % loses its +/// first letter (%rHello is a break and Hello), a run directly before ( is a function +/// name, and a run in capitals, or holding a digit or an inner capital, is an acronym, a dbref or a name. +/// One letter is never marked. +/// +/// +/// Added words are a plain list, one per line, kept beside the dictionaries. A null path keeps them for +/// the run only, which is what a test and a snapshot get. +/// +/// +public sealed class SpellChecker +{ + private readonly WordList _words; + private readonly HashSet _added = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _cache = new(StringComparer.Ordinal); + private readonly string? _userWordsPath; + + /// Most cached answers kept; past this the cache is dropped and refilled. + private const int CacheLimit = 20_000; + + private SpellChecker(WordList words, string name, string? userWordsPath) + { + _words = words; + Name = name; + _userWordsPath = userWordsPath; + if (userWordsPath is not null && File.Exists(userWordsPath)) + { + foreach (var line in File.ReadLines(userWordsPath)) + { + var word = line.Trim(); + if (word.Length > 0) + { + _added.Add(word); + } + } + } + } + + /// The dictionary's name, from its file (en_US). + public string Name { get; } + + /// The words added by hand, for the tests. + public IReadOnlyCollection AddedWords => _added; + + /// A checker over a dictionary already open, for the tests. + public static SpellChecker FromStreams(Stream dic, Stream aff, string name = "test", string? userWordsPath = null) => + new(WordList.CreateFromStreams(dic, aff), name, userWordsPath); + + /// + /// Opens the dictionary to use from , in order, or returns null when none of them + /// holds one. Within a folder the one named for wins (en_GB), then one in + /// its language (en_*), then the first by name. + /// + /// A locale as en_GB or en-GB, or null for no preference. + public static SpellChecker? Find(IEnumerable folders, string? language, string? userWordsPath) + { + var wanted = (language ?? string.Empty).Replace('-', '_'); + var prefix = wanted.Split('_')[0] + "_"; + + foreach (var folder in folders) + { + string[] dics; + try + { + if (!Directory.Exists(folder)) + { + continue; + } + + dics = Directory.GetFiles(folder, "*.dic") + .Where(d => File.Exists(Path.ChangeExtension(d, ".aff"))) + .Order(StringComparer.Ordinal) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + continue; + } + + if (dics.Length == 0) + { + continue; + } + + var chosen = dics.FirstOrDefault(d => wanted.Length > 0 && string.Equals(Path.GetFileNameWithoutExtension(d), wanted, StringComparison.OrdinalIgnoreCase)) + ?? dics.FirstOrDefault(d => prefix.Length > 1 && Path.GetFileNameWithoutExtension(d).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + ?? dics[0]; + + try + { + return new SpellChecker( + WordList.CreateFromFiles(chosen, Path.ChangeExtension(chosen, ".aff")), + Path.GetFileNameWithoutExtension(chosen), + userWordsPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or FormatException) + { + continue; + } + } + + return null; + } + + /// + /// The folders a platform keeps Hunspell dictionaries in, after . Only folders, not a + /// promise any exists: skips what is not there. + /// + public static IReadOnlyList SearchFolders(string? own) + { + var folders = new List(); + if (own is not null) + { + folders.Add(own); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (OperatingSystem.IsMacOS()) + { + folders.Add(Path.Combine(home, "Library", "Spelling")); + folders.Add("/Library/Spelling"); + } + else if (!OperatingSystem.IsWindows()) + { + folders.Add(Path.Combine(home, ".local", "share", "hunspell")); + folders.Add("/usr/share/hunspell"); + folders.Add("/usr/local/share/hunspell"); + folders.Add("/usr/share/myspell"); + folders.Add("/usr/share/myspell/dicts"); + } + + return folders; + } + + /// + /// The user's language from the POSIX locale variables (LC_ALL, LC_MESSAGES, LANG), as + /// en_GB, or null. Read from the environment because the client runs with invariant globalization, + /// where every CultureInfo is the invariant one and names no language. + /// + public static string? PreferredLanguage() + { + foreach (var name in new[] { "LC_ALL", "LC_MESSAGES", "LANG" }) + { + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || value is "C" or "POSIX" || value.StartsWith("C.", StringComparison.Ordinal)) + { + continue; + } + + return value.Split('.', '@')[0]; + } + + return null; + } + + /// Whether a word is spelt right, or was added. + public bool Check(string word) + { + if (_added.Contains(word)) + { + return true; + } + + if (_cache.TryGetValue(word, out var known)) + { + return known; + } + + if (_cache.Count >= CacheLimit) + { + _cache.Clear(); + } + + var ok = _words.Check(word); + _cache[word] = ok; + return ok; + } + + /// The words on this checker does not know, in order. + public IReadOnlyList Misspellings(string line) + { + var found = new List(); + foreach (var (start, length) in Words(line)) + { + if (!Check(line.Substring(start, length))) + { + found.Add(new Misspelling(start, length)); + } + } + + return found; + } + + /// + /// Adds a word, so it is never marked again, and appends it to the list on disk. Returns false for + /// something that is not one word. + /// + public bool Add(string word) + { + word = word.Trim(); + if (word.Length == 0 || word.Any(c => !IsWordChar(c))) + { + return false; + } + + if (!_added.Add(word)) + { + return true; + } + + if (_userWordsPath is { } path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.AppendAllText(path, word + "\n"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Kept for the run; the next launch will mark it again, which is the whole cost. + } + } + + return true; + } + + /// The word at on (or just before it), or null. + public static string? WordAt(string line, int column) + { + foreach (var (start, length) in Runs(line)) + { + if (column >= start && column <= start + length) + { + return line.Substring(start, length); + } + } + + return null; + } + + /// The runs of that are words to check, by the rules in the remarks. + internal static IEnumerable<(int Start, int Length)> Words(string line) + { + foreach (var (runStart, runLength) in Runs(line)) + { + var start = runStart; + var length = runLength; + if (start > 0 && line[start - 1] == '%') + { + start++; + length--; + } + + var end = start + length; + if (length < 2 || (end < line.Length && line[end] == '(')) + { + continue; + } + + var word = line.AsSpan(start, length); + if (word.ContainsAny("0123456789") || HasInnerCapital(word)) + { + continue; + } + + yield return (start, length); + } + } + + /// Letters, digits and inner apostrophes, the way a word processor splits a line. + private static IEnumerable<(int Start, int Length)> Runs(string line) + { + var i = 0; + while (i < line.Length) + { + if (!char.IsLetterOrDigit(line[i])) + { + i++; + continue; + } + + var start = i; + while (i < line.Length + && (char.IsLetterOrDigit(line[i]) + || (line[i] is '\'' or '’' && i + 1 < line.Length && char.IsLetter(line[i + 1]) && i > start))) + { + i++; + } + + yield return (start, i - start); + } + } + + private static bool HasInnerCapital(ReadOnlySpan word) + { + for (var i = 1; i < word.Length; i++) + { + if (char.IsUpper(word[i])) + { + return true; + } + } + + return false; + } + + private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c is '\'' or '’'; +} diff --git a/src/SharpMUTerm.Core/Workspaces/RailModel.cs b/src/SharpMUTerm.Core/Workspaces/RailModel.cs index c1f128f3..4d9a68d7 100644 --- a/src/SharpMUTerm.Core/Workspaces/RailModel.cs +++ b/src/SharpMUTerm.Core/Workspaces/RailModel.cs @@ -46,7 +46,8 @@ public sealed record RailRow( bool Closed = false, int Unread = 0, string? Chord = null, - string? Target = null); + string? Target = null, + bool InGroup = false); /// A world as projected into the rail: its identity, accent, and characters. public sealed record RailWorld(string Name, string Host, int Port, TerminalColor Accent, IReadOnlyList Characters); @@ -76,7 +77,8 @@ public sealed record RailCharacter( bool Active, int Unread, IReadOnlyList Windows, - string? Chord = null); + string? Chord = null, + bool InGroup = false); /// /// A window in the rail: its title, the workspace id a click activates, the ⌥N chord that goes @@ -158,7 +160,8 @@ public static IReadOnlyList Build(IReadOnlyList worlds) Connected: character.Connected, Unread: character.Unread, Chord: character.Chord, - Target: CommandIds.Character(character.SessionKey))); + Target: CommandIds.Character(character.SessionKey), + InGroup: character.InGroup)); if (!character.Active) { diff --git a/src/SharpMUTerm.Scripting/IScriptWorld.cs b/src/SharpMUTerm.Scripting/IScriptWorld.cs index 514cb80a..45520960 100644 --- a/src/SharpMUTerm.Scripting/IScriptWorld.cs +++ b/src/SharpMUTerm.Scripting/IScriptWorld.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + namespace SharpMUTerm.Scripting; /// @@ -11,6 +13,12 @@ public interface IScriptWorld /// Queues or sends a command to the server. void Send(string command); + /// + /// Sends a command to another open, connected character (world.sendTo), named as World.Character + /// or by character name alone. Null when it went; otherwise why not. + /// + string? SendTo(string character, string command) => "this client cannot send to another character"; + /// Writes a line of text to the client output. void Print(string text); @@ -33,6 +41,30 @@ public interface IScriptWorld /// Schedules a one-shot callback; disposing the handle cancels it. IDisposable ScheduleAfter(TimeSpan delay, Action callback); + /// + /// The GMCP or MSDP data at a dotted path (Char.Vitals.hp, MSDP.HEALTH), or null — what + /// gmcp.get and msdp.get read. + /// + JsonNode? GameData(string path); + + /// Sends a GMCP message; is the body, empty for none. + void SendGmcp(string package, string json); + + /// The room the map says the character is in, for map.current(); null with no map or no room. + ScriptRoom? CurrentRoom(); + + /// + /// The commands that walk from the current room to (a room number or name), for + /// map.path(); null when the map knows no way. + /// + IReadOnlyList? PathTo(string target); + + /// Starts a speedwalk to , for map.walk(): null, or why it did not start. + string? WalkTo(string target); + /// The display name of the world, exposed to scripts as world.name. string WorldName { get; } } + +/// A room as a script sees it. +public sealed record ScriptRoom(long Id, string Name, string? Area); diff --git a/src/SharpMUTerm.Scripting/LuaJson.cs b/src/SharpMUTerm.Scripting/LuaJson.cs new file mode 100644 index 00000000..aa29be05 --- /dev/null +++ b/src/SharpMUTerm.Scripting/LuaJson.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using MoonSharp.Interpreter; + +namespace SharpMUTerm.Scripting; + +/// +/// Converts between game data (JSON) and Lua values, for gmcp.get, msdp.get and +/// gmcp.send. An object is a table with string keys, an array a table indexed from 1. +/// +internal static class LuaJson +{ + /// Deeper than this and a Lua table is refused: a table can contain itself. + private const int MaxDepth = 32; + + public static DynValue ToLua(Script script, JsonNode? node) => node switch + { + null => DynValue.Nil, + JsonObject obj => Table(script, obj), + JsonArray array => Table(script, array), + JsonValue value => Scalar(value), + _ => DynValue.Nil, + }; + + /// A Lua value as JSON, or null for nil. Throws for a function, a cycle or too much nesting. + public static JsonNode? FromLua(DynValue value, int depth = 0, string context = "gmcp.send") + { + if (depth > MaxDepth) + { + throw new ScriptRuntimeException($"{context}: the table is nested too deeply (or contains itself)."); + } + + switch (value.Type) + { + case DataType.Nil: + case DataType.Void: + return null; + case DataType.Boolean: + return JsonValue.Create(value.Boolean); + case DataType.Number: + var n = value.Number; + return n == Math.Floor(n) && Math.Abs(n) < 9e15 ? JsonValue.Create((long)n) : JsonValue.Create(n); + case DataType.String: + return JsonValue.Create(value.String); + case DataType.Table: + return FromTable(value.Table, depth, context); + default: + throw new ScriptRuntimeException($"{context}: a {value.Type.ToLuaTypeString()} cannot be stored or sent."); + } + } + + private static JsonNode FromTable(Table table, int depth, string context) + { + var length = table.Length; + var count = table.Pairs.Count(); + if (length > 0 && length == count) + { + var array = new JsonArray(); + for (var i = 1; i <= length; i++) + { + array.Add(FromLua(table.Get(i), depth + 1, context)); + } + + return array; + } + + var obj = new JsonObject(); + foreach (var pair in table.Pairs) + { + var key = pair.Key.Type switch + { + DataType.String => pair.Key.String, + DataType.Number => pair.Key.Number.ToString(CultureInfo.InvariantCulture), + _ => throw new ScriptRuntimeException($"{context}: a table key must be a string or a number."), + }; + obj[key] = FromLua(pair.Value, depth + 1, context); + } + + return obj; + } + + private static DynValue Table(Script script, JsonObject obj) + { + var table = new Table(script); + foreach (var (key, child) in obj) + { + table[key] = ToLua(script, child); + } + + return DynValue.NewTable(table); + } + + private static DynValue Table(Script script, JsonArray array) + { + var table = new Table(script); + for (var i = 0; i < array.Count; i++) + { + table[i + 1] = ToLua(script, array[i]); + } + + return DynValue.NewTable(table); + } + + private static DynValue Scalar(JsonValue value) + { + if (value.TryGetValue(out string? text)) + { + return DynValue.NewString(text); + } + + if (value.TryGetValue(out bool flag)) + { + return DynValue.NewBoolean(flag); + } + + return double.TryParse(value.ToJsonString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var n) + ? DynValue.NewNumber(n) + : DynValue.Nil; + } +} diff --git a/src/SharpMUTerm.Scripting/ScriptHost.cs b/src/SharpMUTerm.Scripting/ScriptHost.cs index 5de53516..503fdb86 100644 --- a/src/SharpMUTerm.Scripting/ScriptHost.cs +++ b/src/SharpMUTerm.Scripting/ScriptHost.cs @@ -8,7 +8,7 @@ namespace SharpMUTerm.Scripting; /// Hosts a single sandboxed Lua environment for a world and bridges it to an /// . Owns the MoonSharp , injects the client API /// (world, output, trigger, alias, timer, gmcp, -/// log), and routes world events (trigger fires, GMCP messages) back into Lua callbacks. +/// store, log), and routes world events (trigger fires, GMCP messages) back into Lua callbacks. /// /// /// Sandbox. The module set is hand-picked (see ): no @@ -40,9 +40,11 @@ public sealed class ScriptHost : IDisposable CoreModules.Json; private readonly IScriptWorld _world; + private readonly ScriptStore _store; private readonly object _gate = new(); private readonly Dictionary _triggerCallbacks = new(StringComparer.Ordinal); private readonly Dictionary _aliasCallbacks = new(StringComparer.Ordinal); + private readonly HashSet _reportedMissing = new(StringComparer.Ordinal); private readonly List<(string Package, DynValue Handler)> _gmcpHandlers = new(); private readonly List _timers = new(); private readonly List _loadedFiles = new(); @@ -51,9 +53,12 @@ public sealed class ScriptHost : IDisposable private int _callbackSeed; private bool _disposed; - public ScriptHost(IScriptWorld world) + /// What the scripts act on. + /// What store.* reads and writes; null gives a store kept in memory only. + public ScriptHost(IScriptWorld world, ScriptStore? store = null) { _world = world ?? throw new ArgumentNullException(nameof(world)); + _store = store ?? new ScriptStore(null); ResetScript(); } @@ -153,8 +158,11 @@ public void Reload() } } - /// Delivers a trigger fire into Lua. No-op if the callback id is unknown (e.g. post-reload). - public void DispatchTrigger(string callbackId, string wholeMatch, IReadOnlyList groups) + /// + /// Delivers a trigger fire into Lua. Returns false, doing nothing, if the callback id is not one + /// trigger.add registered (e.g. post-reload, or a configured rule naming a global function). + /// + public bool DispatchTrigger(string callbackId, string wholeMatch, IReadOnlyList groups) { ArgumentNullException.ThrowIfNull(callbackId); DynValue fn; @@ -162,7 +170,7 @@ public void DispatchTrigger(string callbackId, string wholeMatch, IReadOnlyList< { if (_disposed || !_triggerCallbacks.TryGetValue(callbackId, out fn!)) { - return; + return false; } } @@ -177,19 +185,70 @@ public void DispatchTrigger(string callbackId, string wholeMatch, IReadOnlyList< } InvokeCallback(fn, args); + return true; } - /// Delivers an alias fire into Lua. No-op if the callback id is unknown. - public void DispatchAlias(string callbackId, string wholeMatch, IReadOnlyList groups) + /// Delivers an alias fire into Lua. Returns false, doing nothing, if the callback id is unknown. + public bool DispatchAlias(string callbackId, string wholeMatch, IReadOnlyList groups) { ArgumentNullException.ThrowIfNull(callbackId); DynValue fn; lock (_gate) { if (_disposed || !_aliasCallbacks.TryGetValue(callbackId, out fn!)) + { + return false; + } + } + + var args = new object[1 + (groups?.Count ?? 0)]; + args[0] = wholeMatch ?? string.Empty; + if (groups is not null) + { + for (var i = 0; i < groups.Count; i++) + { + args[i + 1] = groups[i] ?? string.Empty; + } + } + + InvokeCallback(fn, args); + return true; + } + + /// + /// Calls the global function a configured rule names — greet, or combat.onHit for a + /// function in a table — with the whole match and its groups. A rule in F2 names a function the + /// user's own file defines; only reaches functions registered at + /// runtime with trigger.add, which have generated ids. + /// + /// A name that resolves to no function is reported through once per name per load, + /// not once per matching line: a busy trigger pointed at a typo would otherwise flood the message log. + /// + /// + public void DispatchNamed(string name, string wholeMatch, IReadOnlyList groups) + { + ArgumentException.ThrowIfNullOrEmpty(name); + DynValue fn; + bool report; + lock (_gate) + { + if (_disposed) { return; } + + fn = ResolveGlobal(name); + report = fn.Type is not (DataType.Function or DataType.ClrFunction) && _reportedMissing.Add(name); + } + + if (fn.Type is not (DataType.Function or DataType.ClrFunction)) + { + if (report) + { + Error?.Invoke(this, new ScriptException($"No Lua function named '{name}' is defined.")); + } + + return; } var args = new object[1 + (groups?.Count ?? 0)]; @@ -205,6 +264,37 @@ public void DispatchAlias(string callbackId, string wholeMatch, IReadOnlyListTrue when resolves to a function in the loaded scripts. + public bool Defines(string name) + { + if (string.IsNullOrEmpty(name)) + { + return false; + } + + lock (_gate) + { + return !_disposed && ResolveGlobal(name).Type is DataType.Function or DataType.ClrFunction; + } + } + + /// Walks a dotted name through the globals; anything that is not there is nil. + private DynValue ResolveGlobal(string name) + { + var current = DynValue.NewTable(_script.Globals); + foreach (var part in name.Split('.')) + { + if (current.Type != DataType.Table || part.Length == 0) + { + return DynValue.Nil; + } + + current = current.Table.Get(part); + } + + return current; + } + /// Delivers a GMCP message into Lua, firing every handler whose package matches. public void DispatchGmcp(string package, string json) { @@ -268,11 +358,20 @@ private void ResetScript() _triggerCallbacks.Clear(); _aliasCallbacks.Clear(); _gmcpHandlers.Clear(); + _reportedMissing.Clear(); _script = new Script(SandboxModules); InjectApi(_script); } + private void StoreSet(string key, DynValue value) + { + if (_store.Set(key ?? string.Empty, LuaJson.FromLua(value, context: "store.set")) is { } refusal) + { + throw new ScriptRuntimeException("store.set: " + refusal); + } + } + private string NextCallbackId(string prefix) { var n = Interlocked.Increment(ref _callbackSeed); @@ -287,6 +386,11 @@ private void InjectApi(Script script) var world = new Table(script) { ["send"] = (Action)(cmd => _world.Send(cmd ?? string.Empty)), + // sendTo(name, cmd) returns true, or false and why — the map.walk shape. + ["sendTo"] = (Func)((name, cmd) => + _world.SendTo(name ?? string.Empty, cmd ?? string.Empty) is { } refusal + ? DynValue.NewTuple(DynValue.False, DynValue.NewString(refusal)) + : DynValue.True), ["print"] = (Action)(text => _world.Print(text ?? string.Empty)), ["name"] = _world.WorldName, }; @@ -324,13 +428,53 @@ private void InjectApi(Script script) }; globals["timer"] = timer; - // gmcp.* + // gmcp.* — `on` hears a package arrive, `get` reads what the session holds, `send` writes one. var gmcp = new Table(script) { ["on"] = (Action)OnGmcp, + ["get"] = (Func)(path => LuaJson.ToLua(script, _world.GameData(path ?? string.Empty))), + ["send"] = (Action)SendGmcp, }; globals["gmcp"] = gmcp; + // msdp.* — read only: what to REPORT is the status row's to ask for, at the moment MSDP is agreed. + var msdp = new Table(script) + { + ["get"] = (Func)(name => + LuaJson.ToLua(script, _world.GameData("MSDP." + (name ?? string.Empty)))), + }; + globals["msdp"] = msdp; + + // map.* — read the recorded map and walk it. `walk` returns true, or false and why. + var map = new Table(script) + { + ["current"] = (Func)(() => CurrentRoom(script)), + ["path"] = (Func)(target => MapPath(script, target)), + ["walk"] = (Func)(target => + _world.WalkTo(target ?? string.Empty) is { } problem + ? DynValue.NewTuple(DynValue.False, DynValue.NewString(problem)) + : DynValue.True), + }; + globals["map"] = map; + + // store.* — values kept between runs, for this character. set(key, nil) removes one. + var store = new Table(script) + { + ["get"] = (Func)(key => LuaJson.ToLua(script, _store.Get(key ?? string.Empty))), + ["set"] = (Action)StoreSet, + ["keys"] = (Func)(() => + { + var keys = new Table(script); + foreach (var key in _store.Keys) + { + keys.Append(DynValue.NewString(key)); + } + + return DynValue.NewTable(keys); + }), + }; + globals["store"] = store; + // log.* var log = new Table(script) { @@ -359,6 +503,60 @@ private void InjectApi(Script script) }); } + private DynValue CurrentRoom(Script script) + { + if (_world.CurrentRoom() is not { } room) + { + return DynValue.Nil; + } + + var table = new Table(script) + { + ["id"] = (double)room.Id, + ["name"] = room.Name, + }; + if (room.Area is { } area) + { + table["area"] = area; + } + + return DynValue.NewTable(table); + } + + private DynValue MapPath(Script script, string? target) + { + if (_world.PathTo(target ?? string.Empty) is not { } steps) + { + return DynValue.Nil; + } + + var table = new Table(script); + foreach (var step in steps) + { + table.Append(DynValue.NewString(step)); + } + + return DynValue.NewTable(table); + } + + /// + /// gmcp.send(package, value). A string is sent as it is — raw JSON, Mudlet's sendGMCP + /// convention — and anything else is serialised, so gmcp.send("Char.Skills.Get", {group = "combat"}) + /// works without a JSON library in the sandbox. + /// + private void SendGmcp(string package, DynValue value) + { + if (string.IsNullOrWhiteSpace(package)) + { + throw new ScriptRuntimeException("gmcp.send: a package name is required."); + } + + var body = value.Type == DataType.String + ? value.String + : LuaJson.FromLua(value)?.ToJsonString() ?? string.Empty; + _world.SendGmcp(package.Trim(), body); + } + private void AddTrigger(string pattern, DynValue fn) { RequireFunction(fn, "trigger.add"); diff --git a/src/SharpMUTerm.Scripting/ScriptStore.cs b/src/SharpMUTerm.Scripting/ScriptStore.cs new file mode 100644 index 00000000..5ed5294c --- /dev/null +++ b/src/SharpMUTerm.Scripting/ScriptStore.cs @@ -0,0 +1,221 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Scripting; + +/// +/// What one character's scripts keep between runs: store.get, store.set and store.keys. +/// A JSON object of named values, written to one file per character. +/// +/// +/// +/// No path means memory only, by construction, the way MsspCache is built: the host always has a +/// store, so a script never needs to ask whether one exists, and a snapshot or a test that builds a store with +/// no path writes nothing. +/// +/// +/// Bounded at the door. A key is at most characters, there are at most +/// of them, and the whole file at most bytes. A set that +/// would pass a bound is refused and changes nothing, so a runaway timer cannot fill the disk. +/// +/// +/// Written on every change that changes something, through a temporary file and a rename, so a crash +/// leaves the old file or the new one and never half of either. A file that does not parse is moved aside to +/// .bad rather than overwritten by the next set. +/// +/// +public sealed class ScriptStore +{ + public const int MaxKeys = 1024; + public const int MaxKeyLength = 128; + public const int MaxBytes = 256 * 1024; + + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + private readonly string? _path; + private readonly object _gate = new(); + private readonly JsonObject _values; + + /// The file to keep the values in; null keeps them in memory only. + /// Where a file that could not be read or written is said. + public ScriptStore(string? path, Action? report = null) + { + _path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path); + Report = report ?? (_ => { }); + _values = Load(); + } + + private Action Report { get; } + + /// The file this store writes, or null when it writes none. + public string? FilePath => _path; + + /// The value under , as a copy, or null. + public JsonNode? Get(string key) + { + lock (_gate) + { + return _values[key ?? string.Empty]?.DeepClone(); + } + } + + /// Every key, in the order they were first set. + public IReadOnlyList Keys + { + get + { + lock (_gate) + { + return _values.Select(pair => pair.Key).ToArray(); + } + } + } + + /// + /// Sets to , or removes it when the value is null. Returns + /// null, or why nothing was changed. + /// + public string? Set(string key, JsonNode? value) + { + if (string.IsNullOrEmpty(key)) + { + return "a key is required."; + } + + if (key.Length > MaxKeyLength) + { + return $"a key may be at most {MaxKeyLength} characters."; + } + + lock (_gate) + { + var previous = _values[key]; + if (value is null) + { + if (!_values.ContainsKey(key)) + { + return null; + } + + _values.Remove(key); + Write(); + return null; + } + + if (previous is not null && JsonNode.DeepEquals(previous, value)) + { + return null; + } + + if (previous is null && _values.Count >= MaxKeys) + { + return $"the store already holds {MaxKeys} keys."; + } + + var copy = value.DeepClone(); + _values[key] = copy; + if (Encoding.UTF8.GetByteCount(_values.ToJsonString()) > MaxBytes) + { + if (previous is null) + { + _values.Remove(key); + } + else + { + _values[key] = previous.DeepClone(); + } + + return $"the store may hold at most {MaxBytes / 1024} KB."; + } + + Write(); + return null; + } + } + + private JsonObject Load() + { + if (_path is null || !File.Exists(_path)) + { + return new JsonObject(); + } + + try + { + if (JsonNode.Parse(File.ReadAllText(_path)) is JsonObject values) + { + return values; + } + } + catch (JsonException) + { + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Report($"Script store {_path} could not be read: {ex.Message}"); + return new JsonObject(); + } + + try + { + File.Move(_path, _path + ".bad", overwrite: true); + Report($"Script store {_path} was not valid and was moved to {_path}.bad."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Report($"Script store {_path} is not valid and could not be moved aside: {ex.Message}"); + } + + return new JsonObject(); + } + + private void Write() + { + if (_path is null) + { + return; + } + + var temp = _path + ".tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(temp, _values.ToJsonString(Indented)); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(temp, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + File.Move(temp, _path, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Report($"Script store {_path} could not be written: {ex.Message}"); + } + } + + /// + /// The file name a character's store is kept under: the session key with every character a file name + /// may not hold replaced, so two characters never share one and none names a path. + /// + public static string FileNameFor(string sessionKey) + { + var invalid = Path.GetInvalidFileNameChars(); + var name = new StringBuilder(sessionKey.Length); + foreach (var c in sessionKey) + { + name.Append(invalid.Contains(c) || c is '/' or '\\' or ':' ? '_' : c); + } + + var safe = name.ToString().Trim('.', ' '); + if (safe.Length == 0 || !string.Equals(safe, sessionKey, StringComparison.Ordinal)) + { + // A name that had to change could now match another character's; the hash keeps them apart. + var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(sessionKey))); + safe = (safe.Length == 0 ? "_" : safe) + "-" + hash[..8].ToLowerInvariant(); + } + + return safe + ".json"; + } +} diff --git a/src/SharpMUTerm.Scripting/SessionScripts.cs b/src/SharpMUTerm.Scripting/SessionScripts.cs new file mode 100644 index 00000000..8a9911cf --- /dev/null +++ b/src/SharpMUTerm.Scripting/SessionScripts.cs @@ -0,0 +1,205 @@ +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Scripting; + +/// +/// The Lua that runs for one session: a loaded with the files the session's +/// trigger sets name (), rebuilt when that list changes or on request. +/// +/// +/// +/// Files are read from under one root and nowhere else. A trigger set is shared configuration and a +/// package will one day be able to bring one, so a set naming /etc/passwd or ../../.ssh/id_rsa +/// must not be able to make the client read it. A relative name resolves against the root; anything that +/// resolves outside it, absolute or not, is refused and reported. The root is the app's answer to may I +/// run scripts at all, the same shape as the session log's root: an app with none builds none of these. +/// +/// +/// A rebuild drops what the old scripts registered. trigger.add and alias.add put rules +/// in the engines' runtime lists, which a configuration reload deliberately keeps. Re-running the same file +/// over them would register every rule twice, so the runtime lists are cleared first — they hold nothing a +/// script did not put there. +/// +/// +/// Lua runs on whichever thread raised the request — the telnet read loop for a trigger, the UI thread for an +/// alias or a macro, the scheduler for a timer — serialised by the host's own lock. That is the model the +/// bridge was written for, and it keeps a slow script off the UI thread for the commonest case. +/// +/// +public sealed class SessionScripts : IDisposable +{ + private readonly WorldSession _session; + private readonly string _root; + private readonly Action _report; + private readonly Func? _packageScript; + private readonly ScriptStore _store; + private readonly Func? _sendTo; + private WorldSessionScriptBridge? _bridge; + private ScriptHost? _host; + private string[] _files = Array.Empty(); + private bool _disposed; + + /// The session whose rules and output the scripts act on. + /// The directory script files are read from; nothing outside it is read. + /// + /// Where a load failure, a refused path or a script's runtime error is said. Called on whichever thread + /// the failure happened on, so a caller with a UI thread marshals. + /// + /// + /// Resolves a package:<id>/<file> name inside that package's own folder, or null when it + /// names nothing installed. Without it no package script loads. + /// + /// + /// The folder the character's store.* file is kept in, named for the session; null keeps the values + /// in memory only. Either way they outlive a . + /// + /// + /// What world.sendTo(name, command) calls: null when the command went, otherwise why not. Called on the + /// script's thread. Null leaves scripts unable to reach another character. + /// + public SessionScripts( + WorldSession session, + string root, + Action report, + Func? packageScript = null, + string? storeRoot = null, + Func? sendTo = null) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + ArgumentException.ThrowIfNullOrWhiteSpace(root); + _root = Path.GetFullPath(root); + _report = report ?? throw new ArgumentNullException(nameof(report)); + _packageScript = packageScript; + _sendTo = sendTo; + _store = new ScriptStore( + string.IsNullOrWhiteSpace(storeRoot) ? null : Path.Combine(storeRoot, ScriptStore.FileNameFor(session.SessionKey)), + report); + } + + /// What the scripts keep between runs. + public ScriptStore Store => _store; + + /// The file names the host was last built from, as the trigger sets spell them. + public IReadOnlyList Files => _files; + + /// How many of the last actually loaded. + public int LoadedCount { get; private set; } + + /// The live host, or null before the first . + public ScriptHost? Host => _host; + + /// Whether the loaded scripts define a global function by this (possibly dotted) name. + public bool Defines(string name) => _host?.Defines(name) ?? false; + + /// + /// Rebuilds only if the session's file list differs from the one the host was built from, so a settings + /// change that touched nothing scripted does not restart every script's timers. Returns whether it did. + /// + public bool SyncFiles() + { + if (_host is not null && _files.SequenceEqual(_session.ScriptFiles, StringComparer.Ordinal)) + { + return false; + } + + Reload(); + return true; + } + + /// + /// Throws the old host away, drops what it registered, and builds a fresh one from the session's current + /// file list, read from disk again. Returns how many files loaded. + /// + public int Reload() + { + ObjectDisposedException.ThrowIf(_disposed, this); + TearDown(); + _session.Triggers.ClearRuntime(); + _session.Aliases.ClearRuntime(); + + _files = _session.ScriptFiles.ToArray(); + _bridge = new WorldSessionScriptBridge(_session, _sendTo); + _host = new ScriptHost(_bridge, _store); + _bridge.Attach(_host); + _host.Error += OnScriptError; + + var loaded = 0; + foreach (var file in _files) + { + var fromPackage = file.StartsWith("package:", StringComparison.Ordinal); + if ((fromPackage ? _packageScript?.Invoke(file) : Resolve(_root, file)) is not { } path) + { + _report(fromPackage + ? $"Script '{file}' is not in an installed package and was not loaded." + : $"Script '{file}' is outside the scripts folder ({_root}) and was not loaded."); + continue; + } + + try + { + _host.LoadFile(path); + loaded++; + } + catch (ScriptException ex) + { + _report($"Script '{file}': {ex.Message}"); + } + } + + LoadedCount = loaded; + return loaded; + } + + /// + /// The full path names under , or null when it names + /// somewhere else. Comparison is ordinal, so on a case-insensitive file system a differently cased root is + /// refused rather than allowed: the safe direction for this to be wrong in. + /// + public static string? Resolve(string root, string file) + { + if (string.IsNullOrWhiteSpace(file)) + { + return null; + } + + var fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string full; + try + { + full = Path.GetFullPath(file, fullRoot); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + + var prefix = fullRoot + Path.DirectorySeparatorChar; + return full.StartsWith(prefix, StringComparison.Ordinal) ? full : null; + } + + private void OnScriptError(object? sender, ScriptException e) => _report($"Script error: {e.Message}"); + + private void TearDown() + { + if (_host is { } host) + { + host.Error -= OnScriptError; + host.Dispose(); + } + + _bridge?.Dispose(); + _host = null; + _bridge = null; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + TearDown(); + } +} diff --git a/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs b/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs index 835e28e6..aa4c37e2 100644 --- a/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs +++ b/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs @@ -9,21 +9,23 @@ namespace SharpMUTerm.Scripting; /// script-relevant events (trigger fires, GMCP messages) into an attached . /// /// -/// Alias function callbacks are a known gap: the core does not -/// currently raise a script event for script-backed aliases, so a bridged -/// alias.add(pattern, function ... end) registers the alias but its callback is never -/// dispatched. String-substitution aliases and all trigger/GMCP callbacks work end to end. +/// A rule's callback is either an id trigger.add/alias.add generated, or the name of a global +/// function — the F2 script field. Timers and macros only ever carry a name. /// public sealed class WorldSessionScriptBridge : IScriptWorld, IDisposable { private readonly WorldSession _session; + private readonly Func? _sendTo; private ScriptHost? _host; private bool _disposed; - public WorldSessionScriptBridge(WorldSession session) + /// The session the scripts belong to. + /// What world.sendTo calls; null when this client offers no other characters. + public WorldSessionScriptBridge(WorldSession session, Func? sendTo = null) { _session = session ?? throw new ArgumentNullException(nameof(session)); - _session.TriggerScriptRequested += OnTriggerScriptRequested; + _sendTo = sendTo; + _session.ScriptRequested += OnScriptRequested; _session.GmcpReceived += OnGmcpReceived; } @@ -43,6 +45,9 @@ public static ScriptHost CreateHost(WorldSession session) public void Send(string command) => _ = _session.SendRawAsync(command); + public string? SendTo(string character, string command) => + _sendTo is null ? "this client cannot send to another character" : _sendTo(character, command); + public void Print(string text) => _session.PrintSystem(text); public void AddTrigger(string pattern, string callbackId) => @@ -62,22 +67,51 @@ public void AddAlias(string pattern, string substitution, string callbackId) => ScriptCallback = string.IsNullOrEmpty(callbackId) ? null : callbackId, }); + public System.Text.Json.Nodes.JsonNode? GameData(string path) => _session.GameData.Get(path); + + public void SendGmcp(string package, string json) => _ = _session.SendGmcpAsync(package, json); + + public ScriptRoom? CurrentRoom() => + _session.Map?.Current is { } room ? new ScriptRoom(room.Id, room.Name, room.Area) : null; + + public IReadOnlyList? PathTo(string target) => + _session.Map is { } map && map.Find(target) is { } room + ? map.PathTo(room.Id)?.Select(step => step.Command).ToArray() + : null; + + public string? WalkTo(string target) => _session.Map is { } map ? map.WalkTo(target) : "this client keeps no map"; + public IDisposable ScheduleEvery(TimeSpan interval, Action callback) => _session.Scheduler.Every(interval, callback); public IDisposable ScheduleAfter(TimeSpan delay, Action callback) => _session.Scheduler.After(delay, callback); - private void OnTriggerScriptRequested(object? sender, TriggerScriptInvocation e) + /// + /// Runs the callback a rule asked for. A callback trigger.add or alias.add registered + /// has a generated id and is found first; anything else is the name of a global function the user's + /// file defines, which is what F2's script field holds. + /// + private void OnScriptRequested(object? sender, ScriptRequest e) { - var match = e.Match; - var groups = new string[Math.Max(0, match.Groups.Count - 1)]; - for (var i = 1; i < match.Groups.Count; i++) + if (_host is not { } host) { - groups[i - 1] = match.Groups[i].Value; + return; } - _host?.DispatchTrigger(e.Callback, match.Value, groups); + var handled = e.Source switch + { + ScriptSource.Trigger => host.DispatchTrigger(e.Callback, e.WholeMatch, e.Groups), + ScriptSource.Alias => host.DispatchAlias(e.Callback, e.WholeMatch, e.Groups), + _ => false, + }; + + // A generated id ("trigger#3") belongs to a registration a reload has dropped; it is not a name + // anyone typed, so it is not reported as a missing function. + if (!handled && !e.Callback.Contains('#', StringComparison.Ordinal)) + { + host.DispatchNamed(e.Callback, e.WholeMatch, e.Groups); + } } private void OnGmcpReceived(object? sender, GmcpMessageEventArgs e) => @@ -91,7 +125,7 @@ public void Dispose() } _disposed = true; - _session.TriggerScriptRequested -= OnTriggerScriptRequested; + _session.ScriptRequested -= OnScriptRequested; _session.GmcpReceived -= OnGmcpReceived; } } diff --git a/src/SharpMUTerm.Tui/ClientUsage.cs b/src/SharpMUTerm.Tui/ClientUsage.cs index 2f09e353..1577bc43 100644 --- a/src/SharpMUTerm.Tui/ClientUsage.cs +++ b/src/SharpMUTerm.Tui/ClientUsage.cs @@ -70,6 +70,11 @@ internal static class ClientUsage new(WindowHandoff.Flag, "Open in a desktop window instead (sharpmuterm-window), with the other", "arguments. The same client, configuration and keys; see its --help."), ]), + ("Screen readers:", + [ + new(PlainHost.Flag, "Plain text instead of the full screen: output a line at a time as it arrives,", + "and each line typed is sent. End of input quits."), + ]), ("Snapshot (render one frame headlessly and exit):", [ new("--snapshot", "Render one frame (ANSI) headlessly and exit."), diff --git a/src/SharpMUTerm.Tui/ComposeOverlay.cs b/src/SharpMUTerm.Tui/ComposeOverlay.cs index 4a984283..39283969 100644 --- a/src/SharpMUTerm.Tui/ComposeOverlay.cs +++ b/src/SharpMUTerm.Tui/ComposeOverlay.cs @@ -3,6 +3,8 @@ using SharpConsoleUI.Controls; using SharpConsoleUI.Layout; using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Telnet; +using SharpMUTerm.Core.Text; using static SharpMUTerm.Tui.MarkupText; namespace SharpMUTerm.Tui; @@ -10,7 +12,8 @@ namespace SharpMUTerm.Tui; /// What a composed post is, at the moment it is sent or put away. /// The editor's buffer, exactly as typed. /// The window's escaping mode when it happened. -internal readonly record struct ComposeResult(string Body, ComposeEscaping Escaping); +/// The MOO buffer this is an edit of, or null for a post. +internal readonly record struct ComposeResult(string Body, ComposeEscaping Escaping, MooEdit? Edit = null); /// /// The composer: a full-screen editor for writing a post before sending it, over the workspace. @@ -46,8 +49,27 @@ internal sealed class ComposeOverlay private ComposeEscaping _escaping; private string _target = string.Empty; private bool _canSend; + private MooEdit? _edit; + private readonly Func? _openSpelling; + private SpellChecker? _spelling; + private bool _spellingOpened; + + /// The window system the composer opens in. + /// + /// Opens the dictionary, the first time the composer is opened and never again, so a client nobody writes a + /// post in reads no dictionary. Null, or a null answer, is a composer with no spelling, which says so. + /// + internal ComposeOverlay(ConsoleWindowSystem system, Func? spelling = null) + { + _system = system; + _openSpelling = spelling; + } - internal ComposeOverlay(ConsoleWindowSystem system) => _system = system; + /// + /// The dictionary marking this post, or null: none was found, or this is a MOO edit, whose buffer is as often + /// code as prose. + /// + internal SpellChecker? Spelling => _edit is null ? _spelling : null; /// Raised when ⌃S sends. The host decides where it goes and what it does about failure. internal event EventHandler? Send; @@ -67,6 +89,12 @@ internal sealed class ComposeOverlay /// The escaping mode as it stands — the window's own toggle, not a saved setting. internal ComposeEscaping Escaping => _escaping; + /// + /// The MOO buffer being edited, or null for a post. An edit is saved as its lines, with no %r join and + /// no escaping, so ⌥L does nothing while it is open and the footer does not offer it. + /// + internal MooEdit? Edit => _edit; + /// The header row's markup, so a test can read who the window says it will send to. internal string HeaderMarkup => Header(); @@ -82,7 +110,8 @@ internal sealed class ComposeOverlay /// world briefly down is a real thing to be doing — and refuses at the moment of sending instead, /// which is the same rule ⏎ follows on the command line. /// - internal void Toggle(string target, string draft, ComposeEscaping escaping, bool canSend) + /// The MOO buffer this opens on, or null for a post. + internal void Toggle(string target, string draft, ComposeEscaping escaping, bool canSend, MooEdit? edit = null) { if (_window is not null) { @@ -93,6 +122,7 @@ internal void Toggle(string target, string draft, ComposeEscaping escaping, bool _target = target; _escaping = escaping; _canSend = canSend; + _edit = edit; _editor = new MultilineEditControl { @@ -112,7 +142,9 @@ internal void Toggle(string target, string draft, ComposeEscaping escaping, bool // into browse mode — which would make the key mean two different things depending on a // state the writer has no reason to be tracking. EscapeExitsEditMode = false, - PlaceholderText = "Write your post. ⏎ starts a new line; the game gets one command with %r breaks.", + PlaceholderText = edit is null + ? "Write your post. ⏎ starts a new line; the game gets one command with %r breaks." + : "Empty. ⌃S saves it empty.", // The screens' own edit colours, so the composer reads as part of this client rather than as // the control's grey default sitting in a themed window. Both pairs are set: this control @@ -127,6 +159,17 @@ internal void Toggle(string target, string draft, ComposeEscaping escaping, bool SelectionForegroundColor = new Color(ScreenPalette.Value), }; + if (!_spellingOpened) + { + _spellingOpened = true; + _spelling = _openSpelling?.Invoke(); + } + + if (Spelling is { } checker) + { + _editor.SyntaxHighlighter = new SpellingHighlighter(checker); + } + _editor.SetContent(draft); FitEditor(); @@ -197,7 +240,7 @@ private void OnKey(object? sender, KeyPressedEventArgs e) // ⌥L flips the escaping. Alt rather than Ctrl by this codebase's rule — ESC + a printable byte // arrives intact, while half the Ctrl alphabet collapses onto bytes a terminal already spends. - if (alt && key.Key == ConsoleKey.L) + if (alt && key.Key == ConsoleKey.L && _edit is null) { e.Handled = true; _escaping = _escaping == ComposeEscaping.Literal ? ComposeEscaping.AsTyped : ComposeEscaping.Literal; @@ -205,6 +248,15 @@ private void OnKey(object? sender, KeyPressedEventArgs e) return; } + // ⌥W adds the word at the caret to the dictionary. Alt by the same rule as ⌥L, and W because nothing + // in MacroKeys.AppShortcuts takes it — a global shortcut would run before this window ever saw it. + if (alt && key.Key == ConsoleKey.W && Spelling is not null) + { + e.Handled = true; + AddWordAtCaret(); + return; + } + if (key.Key == ConsoleKey.Escape && !ctrl && !alt) { e.Handled = true; @@ -214,6 +266,30 @@ private void OnKey(object? sender, KeyPressedEventArgs e) // Everything else is the editor's, including ⏎, the arrows, ⌃Z and a selection drag. } + /// + /// Adds the word the caret is in, or just after, to the player's word list and repaints the marks. Nothing + /// happens on a caret in no word. + /// + private void AddWordAtCaret() + { + if (_editor is null || Spelling is not { } checker) + { + return; + } + + var lines = _editor.GetContent().Split('\n'); + var row = _editor.CurrentLine - 1; + if (row < 0 || row >= lines.Length || SpellChecker.WordAt(lines[row], _editor.CurrentColumn - 1) is not { } word) + { + return; + } + + if (checker.Add(word)) + { + _editor.RefreshSyntaxHighlighting(); + } + } + private void SendNow() { if (_editor is null) @@ -225,7 +301,7 @@ private void SendNow() // the close so the handler sees the buffer rather than an empty window — and Closed still // follows, so a send the host refuses (no connection) keeps the post rather than dropping it on // the floor. - Send?.Invoke(this, new ComposeResult(_editor.GetContent(), _escaping)); + Send?.Invoke(this, new ComposeResult(_editor.GetContent(), _escaping, _edit)); } /// Shuts the window, handing the buffer back for the host to keep. @@ -236,7 +312,7 @@ internal void Close() return; } - var result = new ComposeResult(Body, _escaping); + var result = new ComposeResult(Body, _escaping, _edit); _system.CloseModalWindow(window); Closed?.Invoke(this, result); } @@ -291,28 +367,48 @@ private string Header() ? $"[{ScreenPalette.Warn}]no connection[/]" : $"[{ScreenPalette.Value}]{Escape(_target)}[/]"; + if (_edit is { } edit) + { + return $"[{ScreenPalette.Accent}]EDIT[/] [{ScreenPalette.Value}]{Escape(edit.Name)}[/]" + + $"[{ScreenPalette.Label}] {Escape(edit.Type)} · from [/]{target}"; + } + var mode = _escaping == ComposeEscaping.Literal ? $"[{ScreenPalette.Accent}]literal[/]" : $"[{ScreenPalette.Value}]as typed[/]"; + var spelling = _spelling is { } checker + ? $"[{ScreenPalette.Value}]{Escape(checker.Name)}[/]" + : $"[{ScreenPalette.Muted}]no dictionary[/]"; + return $"[{ScreenPalette.Accent}]COMPOSE[/][{ScreenPalette.Label}] to [/]{target}" - + $"[{ScreenPalette.Label}] · text [/]{mode}"; + + $"[{ScreenPalette.Label}] · text [/]{mode}" + + $"[{ScreenPalette.Label}] · spelling [/]{spelling}"; } /// - /// The key row. It names ⌃S, ⌥L and Esc and nothing else, because this screen is held to the same + /// The key row. It names ⌃S, ⌥L, ⌥W (while there is a dictionary) and Esc and nothing else, because this screen is held to the same /// honesty rule as the settings screens: every key printed here works, and none that works is /// hidden. There is deliberately no ⌃F find — the control has a find API but no chord bound /// to it, and advertising one would send a reader to press a key that does nothing. /// private string Footer() { + if (_edit is not null) + { + return (_canSend + ? $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] save[/]" + : $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] save (nothing connected)[/]") + + $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]Esc[/][{ScreenPalette.Label}] close (keeps the edit)[/]"; + } + var send = _canSend ? $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] send[/]" : $"[{ScreenPalette.Accent}]⌃S[/][{ScreenPalette.Label}] send (nothing connected)[/]"; return send + $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]⌥L[/][{ScreenPalette.Label}] escaping[/]" + + (Spelling is null ? string.Empty : $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]⌥W[/][{ScreenPalette.Label}] add word[/]") + $"[{ScreenPalette.Label}] · [/][{ScreenPalette.Accent}]Esc[/][{ScreenPalette.Label}] close (keeps the draft)[/]"; } @@ -359,5 +455,6 @@ private void Reset() _system.ConsoleDriver.ScreenResized -= OnScreenResized; _window = null; _editor = null; + _edit = null; } } diff --git a/src/SharpMUTerm.Tui/GameDataOverlay.cs b/src/SharpMUTerm.Tui/GameDataOverlay.cs new file mode 100644 index 00000000..6b3df9e4 --- /dev/null +++ b/src/SharpMUTerm.Tui/GameDataOverlay.cs @@ -0,0 +1,200 @@ +using System.Globalization; +using SharpMUTerm.Core.Protocols; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace SharpMUTerm.Tui; + +/// +/// The game data inspector (⌃P ▸ Show game data): every GMCP package and MSDP variable the focused +/// character's connection holds, how long ago each last changed, and the selected one's data in full. +/// ↑↓ select, Esc closes. +/// +/// It exists because a meter or a script is written against a path, and the only way to know the path is to +/// see what the server sends. Mudlet's equivalent is a tree; a list and one pretty-printed payload answer the +/// same question in the space a terminal has. +/// +/// +internal sealed class GameDataOverlay +{ + private readonly ConsoleWindowSystem _system; + private readonly TimeProvider _time; + + private Window? _window; + private MarkupControl? _content; + private IReadOnlyList _entries = []; + private string _owner = string.Empty; + private int _selected; + + public GameDataOverlay(ConsoleWindowSystem system, TimeProvider time) + { + _system = system; + _time = time; + } + + public bool IsOpen => _window is not null; + + /// Opens the inspector on if closed, closes it if open. + public void Toggle(GameDataStore data, string owner) + { + if (_window is not null) + { + Close(); + return; + } + + _entries = data.Entries; + _owner = owner; + _selected = 0; + + var desktop = _system.DesktopDimensions; + var width = Math.Max(44, desktop.Width - 8); + var height = Math.Max(10, desktop.Height - 4); + _content = new MarkupControl(Lines(width - 3, height - 2)); + + // Centered() after WithSize(), for CommandPalette's reason. + _window = new WindowBuilder(_system) + .WithTitle("Game data") + .AsModal() + .WithBorderStyle(BorderStyle.Single) + .WithBackgroundColor(new Color(ScreenPalette.MenuBg)) + .HideTitleButtons() + .Resizable(false) + .WithSize(width, height) + .Centered() + .AddControl(_content) + .OnClosed((_, _) => Reset()) + .Build(); + + _window.PreviewKeyPressed += OnKey; + _system.AddWindow(_window); + } + + /// + /// The header, one row per package, a rule, and the selected package's data — as many of its lines as fit. + /// Internal so the layout is testable without a window. + /// + internal List Lines(int width, int height) + { + var lines = new List + { + $"[{ScreenPalette.Accent}]{Escape(_owner)}[/] [{ScreenPalette.Label}]· {Count(_entries.Count)} · ↑↓ select · Esc close[/]", + string.Empty, + }; + + if (_entries.Count == 0) + { + lines.Add($"[{ScreenPalette.Label}]nothing yet — this connection has received no GMCP or MSDP data.[/]"); + return lines; + } + + // The list takes at most half the room, so the payload under it is always readable; it scrolls to + // keep the selection in view. + var listRows = Math.Clamp(_entries.Count, 1, Math.Max(3, (height - 4) / 2)); + var first = Math.Clamp(_selected - (listRows - 1), 0, Math.Max(0, _entries.Count - listRows)); + var nameWidth = Math.Min(32, _entries.Max(e => e.Package.Length)); + var now = _time.GetUtcNow(); + for (var i = first; i < first + listRows && i < _entries.Count; i++) + { + var entry = _entries[i]; + var marker = i == _selected ? $"[bold {ScreenPalette.Accent}]▸[/]" : " "; + var name = Elide(entry.Package, nameWidth).PadRight(nameWidth); + var age = Age(now - entry.UpdatedAt).PadLeft(7); + var preview = Elide(entry.Data?.ToJsonString() ?? "(no data)", Math.Max(4, width - nameWidth - 12)); + lines.Add($"{marker} [{(i == _selected ? "bold " : string.Empty)}{ScreenPalette.Value}]{Escape(name)}[/]" + + $" [{ScreenPalette.Label}]{age} {Escape(preview)}[/]"); + } + + lines.Add($"[{ScreenPalette.Label}]{new string('─', Math.Max(1, width))}[/]"); + var payload = GameDataStore.Pretty(_entries[_selected].Data).Split('\n'); + var room = Math.Max(1, height - lines.Count); + foreach (var row in payload.Take(room)) + { + lines.Add($"[{ScreenPalette.Value}]{Escape(Elide(row.TrimEnd('\r'), width))}[/]"); + } + + if (payload.Length > room) + { + lines[^1] = $"[{ScreenPalette.Label}]… {payload.Length - room + 1} more lines[/]"; + } + + return lines; + } + + internal void Select(int delta) + { + if (_entries.Count == 0) + { + return; + } + + _selected = Math.Clamp(_selected + delta, 0, _entries.Count - 1); + Repaint(); + } + + private void Repaint() + { + if (_window is { } window) + { + _content?.SetContent(Lines(Math.Max(20, window.Width - 3), Math.Max(6, window.Height - 2))); + } + } + + private void OnKey(object? sender, KeyPressedEventArgs e) + { + switch (e.KeyInfo.Key) + { + case ConsoleKey.UpArrow: + Select(-1); + e.Handled = true; + return; + case ConsoleKey.DownArrow: + Select(1); + e.Handled = true; + return; + case ConsoleKey.PageUp: + Select(-10); + e.Handled = true; + return; + case ConsoleKey.PageDown: + Select(10); + e.Handled = true; + return; + case ConsoleKey.Escape: + Close(); + e.Handled = true; + return; + } + } + + private void Close() + { + if (_window is { } window) + { + _system.CloseModalWindow(window); + } + } + + private void Reset() + { + _window = null; + _content = null; + _entries = []; + } + + private static string Count(int n) => n == 1 ? "1 package" : $"{n.ToString(CultureInfo.InvariantCulture)} packages"; + + private static string Age(TimeSpan age) => age.TotalSeconds switch + { + < 1 => "now", + < 60 => $"{(int)age.TotalSeconds}s ago", + < 3600 => $"{(int)age.TotalMinutes}m ago", + _ => $"{(int)age.TotalHours}h ago", + }; + + private static string Elide(string text, int width) => + text.Length <= width ? text : width <= 1 ? "…" : text[..(width - 1)] + "…"; + + private static string Escape(string text) => MarkupText.Escape(text); +} diff --git a/src/SharpMUTerm.Tui/GmcpStats.cs b/src/SharpMUTerm.Tui/GmcpStats.cs deleted file mode 100644 index 0141c180..00000000 --- a/src/SharpMUTerm.Tui/GmcpStats.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Text; -using System.Text.Json; - -namespace SharpMUTerm.Tui; - -/// -/// Accumulates GMCP character stats (e.g. Char.Vitals, Char.Stats) into a flat -/// key/value model and renders a compact one-line summary for the stat pane. Deliberately -/// generic: any flat JSON object's scalar fields are captured. -/// -internal sealed class GmcpStats -{ - private static readonly string[] PreferredOrder = - ["hp", "maxhp", "mp", "maxmp", "sp", "maxsp", "level", "xp", "gold"]; - - private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); - - /// Merges a GMCP message. Returns true if any stat changed. - public bool Update(string package, string json) - { - if (!package.StartsWith("Char", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(json)) - { - return false; - } - - JsonElement root; - try - { - using var doc = JsonDocument.Parse(json); - root = doc.RootElement.Clone(); - } - catch (JsonException) - { - return false; - } - - if (root.ValueKind != JsonValueKind.Object) - { - return false; - } - - var changed = false; - foreach (var property in root.EnumerateObject()) - { - if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) - { - continue; - } - - // JsonElement.ToString() yields "True"/"False" for booleans; normalise to JSON casing - // so booleans read consistently with numeric/string fields. - var value = property.Value.ValueKind switch - { - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => property.Value.ToString(), - }; - if (!_values.TryGetValue(property.Name, out var existing) || existing != value) - { - _values[property.Name] = value; - changed = true; - } - } - - return changed; - } - - public bool HasData => _values.Count > 0; - - /// Reads an integer stat (e.g. hp, maxhp), or null if absent/non-numeric. - public int? GetInt(string key) => - _values.TryGetValue(key, out var value) && int.TryParse(value, out var n) ? n : null; - - /// A compact one-line summary, preferred vitals first. - public string Summarize() - { - if (_values.Count == 0) - { - return string.Empty; - } - - var sb = new StringBuilder(); - foreach (var key in PreferredOrder) - { - if (_values.TryGetValue(key, out var value)) - { - Append(sb, key, value); - } - } - - foreach (var (key, value) in _values) - { - if (Array.IndexOf(PreferredOrder, key.ToLowerInvariant()) < 0) - { - Append(sb, key, value); - } - } - - return sb.ToString(); - } - - private static void Append(StringBuilder sb, string key, string value) - { - if (sb.Length > 0) - { - sb.Append(" "); - } - - sb.Append(key).Append(':').Append(value); - } -} diff --git a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs index 40f4c5f5..a9f94c79 100644 --- a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs +++ b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs @@ -1,4 +1,8 @@ using SharpConsoleUI.Drivers; +using SharpMUTerm.Audio; +using SharpMUTerm.Core.Accessibility; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Presence; using SharpMUTerm.Graphics; namespace SharpMUTerm.Tui; @@ -59,6 +63,26 @@ internal sealed record ClientHost /// Pixels per cell for the Sixel overlay; read only when the capabilities offer one. public required (int Width, int Height) SixelCell { get; init; } + /// + /// Opens the sound device, called the first time a game asks for a sound, so a client whose games send + /// none never opens one. Null for no sound at all — the snapshot and the tests, which must never make a + /// noise; a factory that returns null is a machine with no device. + /// + public required Func? Audio { get; init; } + + /// + /// Opens where a game's Discord presence is shown, called the first time a world that allows it has something + /// to show. Null for nowhere: the snapshot and the tests, which must never reach a Discord on the machine. + /// + public required Func? Presence { get; init; } + + /// + /// Where output lines, the client's messages and window changes go for a screen reader: the window's live + /// region, or --plain's stdout. Null in a terminal, whose screen a reader cannot follow, and in the + /// snapshot and the tests. + /// + public required IAnnouncer? Announcer { get; init; } + /// /// Whether the terminal driver's input path can decode a focus report. Not on Windows: the Windows branch of /// NetConsoleDriver is a Console.ReadKey loop with its own reassembly, and an ESC [ I @@ -92,6 +116,9 @@ public static ClientHost Terminal(TerminalCapabilities capabilities) Pointer = TerminalPointer.Create(driver), RunsInline = false, SixelCell = SharpMUTermApp.SixelCellSize(Environment.GetEnvironmentVariable(SharpMUTermApp.SixelCellVariable)), + Audio = () => DesktopAudio.TryCreate(), + Presence = () => new DiscordIpc(), + Announcer = null, }; /// @@ -110,6 +137,9 @@ public static ClientHost Terminal(TerminalCapabilities capabilities) Pointer = null, RunsInline = true, SixelCell = SharpMUTermApp.SixelCellSize(null), + Audio = null, + Presence = null, + Announcer = null, }; /// @@ -126,7 +156,14 @@ public static ClientHost ForHarness( { if (driver is null) { - return Terminal(capabilities) with { Clipboard = clipboard, OpenUrl = openUrl, FocusReporting = focusReporting ?? PlatformDecodesFocusReports }; + return Terminal(capabilities) with + { + Clipboard = clipboard, + OpenUrl = openUrl, + FocusReporting = focusReporting ?? PlatformDecodesFocusReports, + Audio = null, // a test never makes a noise + Presence = null, // nor talks to Discord + }; } var inline = driver is HeadlessConsoleDriver; diff --git a/src/SharpMUTerm.Tui/Hosting/PlainHost.cs b/src/SharpMUTerm.Tui/Hosting/PlainHost.cs new file mode 100644 index 00000000..93246992 --- /dev/null +++ b/src/SharpMUTerm.Tui/Hosting/PlainHost.cs @@ -0,0 +1,101 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Accessibility; +using SharpMUTerm.Core.Presence; +using SharpMUTerm.Audio; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui; + +/// +/// sharpmuterm --plain: the client with no compositor on the terminal, for a screen reader. The app runs +/// unchanged on a headless driver; what reaches the terminal is plain text, a line at a time and in the order it +/// arrived, and what is typed is read a line at a time and sent with ⏎. Blightmud's screen-reader mode and +/// Mudlet's screen-reader setup come to the same thing: a cursor-addressed screen reads as noise. +/// +internal static class PlainHost +{ + /// The flag that selects this host. + public const string Flag = "--plain"; + + /// The host, writing to . + public static ClientHost Create(TerminalCapabilities capabilities, TextWriter output) + { + var (width, height) = TerminalSize(); + return ClientHost.Headless(new HeadlessConsoleDriver(width, height), capabilities) with + { + Name = "plain", + OpenUrl = ExternalBrowser.Open, + RunsInline = false, + Audio = () => DesktopAudio.TryCreate(), + Presence = () => new DiscordIpc(), + Announcer = new PlainAnnouncer(output), + }; + } + + /// + /// Reads a line at a time and types each into the command line followed by ⏎. At the + /// end of input it asks to quit and confirms, the way ⌃Q then Y would. Runs on a thread of its own, started + /// from the client's loop: a key raised before the loop runs has no listener and is lost. + /// + public static void PumpInput(TextReader input, HeadlessConsoleDriver driver, SharpMUTermApp app) + { + var thread = new Thread(() => + { + while (input.ReadLine() is { } line) + { + foreach (var c in line) + { + driver.SimulateKeyPress(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + + driver.SimulateKeyPress(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + } + + driver.SimulateKeyPress(new ConsoleKeyInfo('\x11', ConsoleKey.Q, false, false, true)); + driver.SimulateKeyPress(new ConsoleKeyInfo('y', ConsoleKey.Y, false, false, false)); + }) + { + IsBackground = true, + Name = "plain input", + }; + app.OnUiThread(thread.Start); + } + + /// The terminal's size when it has one, so NAWS tells a server something true; 80×24 otherwise. + private static (int Width, int Height) TerminalSize() + { + try + { + return (Math.Max(Console.WindowWidth, 40), Math.Max(Console.WindowHeight, 10)); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + { + return (80, 24); + } + } +} + +/// +/// Writes announcements as lines of plain text. Output is written as it came; the client's own messages and +/// window changes are marked, so a listener can tell them from the game. +/// +internal sealed class PlainAnnouncer(TextWriter output) : IAnnouncer +{ + private readonly object _gate = new(); + + public void Announce(string text, AnnouncementKind kind) + { + var line = kind switch + { + AnnouncementKind.Notice => "[client] " + text, + AnnouncementKind.Focus => "[window] " + text, + _ => text, + }; + + lock (_gate) + { + output.WriteLine(line); + output.Flush(); + } + } +} diff --git a/src/SharpMUTerm.Tui/Hosting/TerminalPointer.cs b/src/SharpMUTerm.Tui/Hosting/TerminalPointer.cs index 40286480..95438d3c 100644 --- a/src/SharpMUTerm.Tui/Hosting/TerminalPointer.cs +++ b/src/SharpMUTerm.Tui/Hosting/TerminalPointer.cs @@ -57,6 +57,8 @@ public void ShowMenu(System.Drawing.Point cell, IReadOnlyList i { } + public bool DrawsMenus => false; + public void Scrollbars(IReadOnlyList bars) { } diff --git a/src/SharpMUTerm.Tui/InputBarControl.cs b/src/SharpMUTerm.Tui/InputBarControl.cs index a88a13a9..2ae86c63 100644 --- a/src/SharpMUTerm.Tui/InputBarControl.cs +++ b/src/SharpMUTerm.Tui/InputBarControl.cs @@ -106,6 +106,12 @@ internal InputBarControl() /// internal Func CutTo { get; set; } = _ => false; + /// + /// What ⇥ makes of the buffer when the caret is at its end: the completed text, or null when there is + /// nothing to complete, in which case ⇥ goes on to the sibling bar as before. + /// + internal Func Complete { get; set; } = _ => null; + /// Whether ⇥ has somewhere to go — false while this is the only bar on screen. internal Func HasSibling { get; set; } = () => false; @@ -230,6 +236,14 @@ private void ApplyColors() FocusedForegroundColor = Ink; } + /// Whether the caret is after the last character, which is the only place ⇥ completes. + private bool CaretAtEnd() + { + var text = Text; + var last = text.LastIndexOf('\n'); + return CurrentLine == text.Count(c => c == '\n') + 1 && CurrentColumn - 1 == text.Length - (last + 1); + } + /// Whether the bar is holding nothing. internal bool IsEmpty => LineCount <= 1 && Text.Length == 0; @@ -344,6 +358,12 @@ internal int Rows() => return ctrl || shift || alt ? Claim(ConsoleKey.Enter) : Send(); } + if (key.Key == ConsoleKey.Tab && key.Modifiers == 0 && CaretAtEnd() && Complete(Text) is { } completed) + { + Replace(completed, notify: true); + return true; + } + if (key.Key == ConsoleKey.Tab) { if (!HasSibling()) diff --git a/src/SharpMUTerm.Tui/LinkPayload.cs b/src/SharpMUTerm.Tui/LinkPayload.cs index 215a05f0..df17f98c 100644 --- a/src/SharpMUTerm.Tui/LinkPayload.cs +++ b/src/SharpMUTerm.Tui/LinkPayload.cs @@ -60,6 +60,18 @@ internal enum LinkAction /// internal readonly record struct LinkPayload(LinkAction Action, string Target) { + /// + /// An MXP <SEND> menu's entries, first one first; null for a link with one target. The + /// and are the first entry's, so a click is unchanged. + /// + internal IReadOnlyList? Choices { get; init; } + + /// A <SEND> menu; the menu's own entries are percent-encoded inside it. + internal const string MenuScheme = "mux:menu:"; + + /// The same with PROMPT. + internal const string MenuPromptScheme = "mux:menu-prompt:"; + /// MXP <SEND> / Pueblo xch_cmd: a command to write to the wire. internal const string SendScheme = "mux:send:"; @@ -81,6 +93,8 @@ internal readonly record struct LinkPayload(LinkAction Action, string Target) /// internal static string? For(SpanInteraction? interaction) => interaction?.Kind switch { + InteractionKind.SendCommand when interaction.Choices is { Count: > 1 } choices => + (interaction.PromptOnly ? MenuPromptScheme : MenuScheme) + LinkUrl.Escape(EncodeChoices(choices)), InteractionKind.SendCommand when interaction.PromptOnly => PromptScheme + LinkUrl.Escape(interaction.Target), InteractionKind.SendCommand => SendScheme + LinkUrl.Escape(interaction.Target), InteractionKind.Hyperlink => WebScheme + LinkUrl.Escape(interaction.Target), @@ -99,6 +113,8 @@ internal readonly record struct LinkPayload(LinkAction Action, string Target) /// internal static LinkPayload Parse(string? url) => url switch { + not null when url.StartsWith(MenuScheme, StringComparison.Ordinal) => Menu(LinkAction.Send, url[MenuScheme.Length..]), + not null when url.StartsWith(MenuPromptScheme, StringComparison.Ordinal) => Menu(LinkAction.Prompt, url[MenuPromptScheme.Length..]), not null when url.StartsWith(SendScheme, StringComparison.Ordinal) => new(LinkAction.Send, url[SendScheme.Length..]), not null when url.StartsWith(PromptScheme, StringComparison.Ordinal) => @@ -115,4 +131,28 @@ not null when url.StartsWith(WebScheme, StringComparison.Ordinal) => /// checking a chain the app does not use. /// internal static LinkPayload Delivered(string emitted) => Parse(LinkUrl.Unescape(emitted)); + + /// + /// label,command|label,command, each part through , which leaves + /// neither , nor | unescaped — so the separators cannot come from a server's text. + /// + private static string EncodeChoices(IReadOnlyList choices) => + string.Join('|', choices.Select(c => Uri.EscapeDataString(c.Label) + "," + Uri.EscapeDataString(c.Command))); + + private static LinkPayload Menu(LinkAction action, string encoded) + { + var choices = new List(); + foreach (var part in encoded.Split('|')) + { + var comma = part.IndexOf(',', StringComparison.Ordinal); + if (comma < 0) + { + return None; + } + + choices.Add(new SendChoice(Uri.UnescapeDataString(part[..comma]), Uri.UnescapeDataString(part[(comma + 1)..]))); + } + + return choices.Count == 0 ? None : new LinkPayload(action, choices[0].Command) { Choices = choices }; + } } diff --git a/src/SharpMUTerm.Tui/ListOverlay.cs b/src/SharpMUTerm.Tui/ListOverlay.cs new file mode 100644 index 00000000..04deb107 --- /dev/null +++ b/src/SharpMUTerm.Tui/ListOverlay.cs @@ -0,0 +1,139 @@ +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpMUTerm.Core.Packages; + +namespace SharpMUTerm.Tui; + +/// +/// A modal list surface driven by callbacks: the package review and Packages screen (F10), the package +/// index, and the terminal's chooser for an MXP <SEND> menu. What it says is the caller's +/// render; what a key does is the caller's key handler, which says whether the surface stays open. +/// +internal sealed class ListOverlay +{ + private readonly ConsoleWindowSystem _system; + + private Window? _window; + private MarkupControl? _content; + private Func>? _render; + private Func? _key; + + public ListOverlay(ConsoleWindowSystem system) + { + _system = system; + } + + public bool IsOpen => _window is not null; + + /// Which surface is up: review, list or null. + public string? Mode { get; private set; } + + /// What the surface shows now, for a test to read. + internal IReadOnlyList Lines => _render?.Invoke(Width()) ?? []; + + /// + /// Opens a surface. draws it at a width; answers a key + /// and returns whether the surface stays open. + /// + public void Open(string mode, string title, Func> render, Func key, int? width = null) + { + if (_window is not null) + { + Close(); + } + + _render = render; + _key = key; + var desktop = _system.DesktopDimensions; + var widest = Math.Clamp(desktop.Width - 8, 44, 110); + var outer = width is { } asked ? Math.Clamp(asked + 4, 24, widest) : widest; + var lines = render(outer - 4); + var height = Math.Clamp(lines.Count + 2, 8, Math.Max(8, desktop.Height - 2)); + _content = new MarkupControl(lines); + + // Centered() after WithSize(), for CommandPalette's reason. + Window? built = null; + built = new WindowBuilder(_system) + .WithTitle(title) + .AsModal() + .WithBorderStyle(BorderStyle.Single) + .WithBackgroundColor(new Color(ScreenPalette.MenuBg)) + .HideTitleButtons() + .Resizable(false) + .WithSize(outer, height) + .Centered() + .AddControl(_content) + .OnClosed((_, _) => + { + // A window closed because the next surface replaced it must not clear the next one's state. + if (_window == built) + { + Reset(); + } + }) + .Build(); + _window = built; + + _window.PreviewKeyPressed += OnKey; + _system.AddWindow(_window); + + // Last, so a test (or anything else) that waits on the mode finds the window already up. + Mode = mode; + } + + public void Repaint() + { + if (_render is not null && _content is not null) + { + _content.SetContent(_render(Width())); + _window?.Invalidate(redrawAll: true); + } + } + + internal void SimulateKey(ConsoleKeyInfo key) => OnKey(this, new KeyPressedEventArgs(key, false)); + + public void Close() + { + if (_window is { } window) + { + _system.CloseModalWindow(window); + } + } + + private int Width() => Math.Max(20, (_window?.Width ?? 80) - 4); + + private void OnKey(object? sender, KeyPressedEventArgs e) + { + if (_window is null || _key is null) + { + return; + } + + e.Handled = true; + + // A key can start a fetch whose answer opens the next surface (browse, then a review). Only the + // surface the key was pressed on is closed, so that answer is never closed with it. + var window = _window; + if (e.KeyInfo.Key == ConsoleKey.Escape || !_key(e.KeyInfo)) + { + if (_window == window) + { + Close(); + } + + return; + } + + Repaint(); + } + + private void Reset() + { + _window = null; + _content = null; + _render = null; + _key = null; + Mode = null; + } +} diff --git a/src/SharpMUTerm.Tui/MacroKeys.cs b/src/SharpMUTerm.Tui/MacroKeys.cs index cc1b5fa0..e6584d76 100644 --- a/src/SharpMUTerm.Tui/MacroKeys.cs +++ b/src/SharpMUTerm.Tui/MacroKeys.cs @@ -188,6 +188,8 @@ private static AppShortcut[] BuildAppShortcuts() new((ConsoleModifiers)0, ConsoleKey.F7, "opens Text & ANSI"), new((ConsoleModifiers)0, ConsoleKey.F8, "opens Input"), new((ConsoleModifiers)0, ConsoleKey.F9, "opens the character's log"), + // F10 is Packages, the composer's case: a different surface, claimed so F4 says so. + new((ConsoleModifiers)0, ConsoleKey.F10, "opens Packages"), }; /// The claimed chords by canonical descriptor, built once from . diff --git a/src/SharpMUTerm.Tui/MapRenderer.cs b/src/SharpMUTerm.Tui/MapRenderer.cs new file mode 100644 index 00000000..ff701cda --- /dev/null +++ b/src/SharpMUTerm.Tui/MapRenderer.cs @@ -0,0 +1,275 @@ +using System.Globalization; +using System.Text; +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Tui; + +/// +/// Draws one level of one area of a map as rows of markup, centred on where the character stands. +/// +/// A room is three cells, [ ], and the grid is four columns by two rows, which leaves one cell +/// between rooms for an east–west line and one row for the north–south and diagonal ones. The middle cell says +/// what the room is: @ where you are, the room's own symbol when it has one, an arrow when it has a way +/// up or down, otherwise blank. A room on the walk ahead wears the notice ink; a locked one the draft ink. +/// +/// +/// Each room is a link ( and its number), which the map window's own handler +/// answers by walking there. The payload is never a mux:send:: what a click on a map does is the +/// client's decision, and nothing on the map is text a server chose — room names are drawn only in the title. +/// +/// +/// An exit leading to a room off this level or area is drawn as its first segment only; an unexplored exit +/// (a stub) is drawn dim. Nothing is drawn past the box it was given, so a map can never wrap. +/// +/// +internal static class MapRenderer +{ + /// The payload prefix a room's link carries. + internal const string LinkPrefix = "mux:map:"; + + internal const int CellWidth = 4; + + internal const int CellHeight = 2; + + /// The inks a map is drawn in. Hexes, already held to the legibility floor by the caller. + internal readonly record struct Ink(string Title, string Current, string Path, string Locked); + + private enum Kind + { + Blank, + Line, + Stub, + Room, + Current, + Path, + Locked, + } + + private readonly record struct Cell(char Glyph, Kind Kind, long? Room); + + /// The room number a map link names, or null for anything else. + internal static long? ParseLink(string url) => + url.StartsWith(LinkPrefix, StringComparison.Ordinal) + && long.TryParse(url.AsSpan(LinkPrefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var id) + ? id + : null; + + /// + /// The map as rows at most, each at most cells. The first + /// row is the title; the rest is the grid around . With no current room the map is + /// drawn around the lowest-numbered room, and an empty map says so. + /// + internal static List Render( + WorldMap map, + long? current, + IEnumerable ahead, + int width, + int height, + Ink ink, + string? walking = null) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + var lines = new List(); + + var centre = current is { } c ? map.Room(c) : null; + centre ??= map.Rooms.Values.OrderBy(r => r.Id).FirstOrDefault(); + if (centre is null) + { + lines.Add(Title("Map · nothing mapped yet", width, ink)); + lines.Add(Fit("[dim]Walk somewhere: rooms appear as the game reports them.[/]", width)); + return lines.Take(height).ToList(); + } + + var area = centre.AreaId is { } a && map.Areas.TryGetValue(a, out var found) ? found.Name : null; + var title = new StringBuilder("Map"); + if (!string.IsNullOrWhiteSpace(area)) + { + title.Append(" · ").Append(area); + } + + title.Append(" · z ").Append(centre.Z.ToString(CultureInfo.InvariantCulture)); + if (walking is not null) + { + title.Append(" · ").Append(walking); + } + + lines.Add(Title(title.ToString(), width, ink)); + if (height == 1) + { + return lines; + } + + var rows = height - 1; + var grid = new Cell[rows, width]; + var ahead_ = ahead.ToHashSet(); + + // Rooms are placed so the centre room's middle cell lands in the middle of the box. + var originCol = width / 2 - 1; + var originRow = rows / 2; + (int Col, int Row) At(MapRoom room) => + (originCol + (room.X - centre.X) * CellWidth, originRow + (room.Y - centre.Y) * CellHeight); + + var level = map.Rooms.Values + .Where(r => r.Z == centre.Z && r.AreaId == centre.AreaId) + .ToList(); + + void Put(int col, int row, char glyph, Kind kind, long? room = null) + { + if (row < 0 || row >= rows || col < 0 || col >= width) + { + return; + } + + // A room's cells win over a line, a line over a stub. + if (grid[row, col].Kind >= Kind.Room && kind < Kind.Room) + { + return; + } + + if (grid[row, col].Kind == Kind.Line && kind == Kind.Stub) + { + return; + } + + grid[row, col] = new Cell(glyph, kind, room); + } + + foreach (var room in level) + { + var (col, row) = At(room); + foreach (var (_, key, _) in room.AllExits()) + { + Connect(col, row, key, Kind.Line); + } + + foreach (var stub in room.Stubs) + { + if (MapDirections.Normalise(stub) is { } direction) + { + Connect(col, row, direction, Kind.Stub); + } + } + } + + foreach (var room in level) + { + var (col, row) = At(room); + var kind = room.Id == current ? Kind.Current + : room.Locked ? Kind.Locked + : ahead_.Contains(room.Id) ? Kind.Path + : Kind.Room; + Put(col, row, '[', kind, room.Id); + Put(col + 1, row, Middle(room, current), kind, room.Id); + Put(col + 2, row, ']', kind, room.Id); + } + + for (var row = 0; row < rows; row++) + { + lines.Add(Row(grid, row, width, ink)); + } + + return lines; + + void Connect(int col, int row, string direction, Kind kind) + { + switch (direction) + { + case "east": + Put(col + 3, row, '─', kind); + break; + case "west": + Put(col - 1, row, '─', kind); + break; + case "south": + Put(col + 1, row + 1, '│', kind); + break; + case "north": + Put(col + 1, row - 1, '│', kind); + break; + case "southeast": + Put(col + 3, row + 1, '╲', kind); + break; + case "northwest": + Put(col - 1, row - 1, '╲', kind); + break; + case "southwest": + Put(col - 1, row + 1, '╱', kind); + break; + case "northeast": + Put(col + 3, row - 1, '╱', kind); + break; + } + } + } + + private static char Middle(MapRoom room, long? current) + { + if (room.Id == current) + { + return '@'; + } + + if (!string.IsNullOrEmpty(room.Symbol) && !char.IsControl(room.Symbol[0]) && !char.IsSurrogate(room.Symbol[0])) + { + return room.Symbol[0]; + } + + var up = room.Exits.ContainsKey("up"); + var down = room.Exits.ContainsKey("down"); + return up && down ? '↕' : up ? '↑' : down ? '↓' : ' '; + } + + private static string Title(string text, int width, Ink ink) + { + var plain = text.Length > width ? text[..Math.Max(0, width - 1)] + "…" : text; + return $"[bold {ink.Title}]{MarkupText.Escape(plain)}[/]"; + } + + private static string Fit(string markup, int width) => + MarkupText.VisibleLength(markup) <= width ? markup : MarkupText.Escape(MarkupText.Plain(markup)[..width]); + + private static string Row(Cell[,] grid, int row, int width, Ink ink) + { + // Trailing blanks are dropped: a row is no wider than what is drawn on it. + var last = width - 1; + while (last >= 0 && grid[row, last].Kind == Kind.Blank) + { + last--; + } + + var markup = new StringBuilder(); + var col = 0; + while (col <= last) + { + var cell = grid[row, col]; + var end = col + 1; + while (end <= last && grid[row, end].Kind == cell.Kind && grid[row, end].Room == cell.Room) + { + end++; + } + + var text = new StringBuilder(); + for (var i = col; i < end; i++) + { + text.Append(grid[row, i].Kind == Kind.Blank ? ' ' : grid[row, i].Glyph); + } + + var escaped = MarkupText.Escape(text.ToString()); + var styled = cell.Kind switch + { + Kind.Blank or Kind.Line or Kind.Room => escaped, + Kind.Stub => $"[dim]{escaped}[/]", + Kind.Current => $"[bold {ink.Current}]{escaped}[/]", + Kind.Path => $"[{ink.Path}]{escaped}[/]", + Kind.Locked => $"[{ink.Locked}]{escaped}[/]", + _ => escaped, + }; + + markup.Append(cell.Room is { } id ? $"[link={LinkPrefix}{id.ToString(CultureInfo.InvariantCulture)}]{styled}[/]" : styled); + col = end; + } + + return markup.ToString(); + } +} diff --git a/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs b/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs index 30179d70..0e304809 100644 --- a/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs @@ -252,11 +252,11 @@ private static bool IsSpacer(OptionRow row) => /// . Called without arguments it projects the defaults, which is what /// the unit tests and the width-agnostic fallback want. /// - internal static OptionsScreen TextAnsiScreen(TextSettings? text = null) + internal static OptionsScreen TextAnsiScreen(TextSettings? text = null, SoundSettings? sound = null) { var settings = text ?? new TextSettings(); - return new OptionsScreen("Text & ANSI", "F7", new List + var rows = new List { new("├ COLOUR", null, null), new("strip incoming ANSI colour", null, settings.StripIncomingColour, null, @@ -318,9 +318,57 @@ internal static OptionsScreen TextAnsiScreen(TextSettings? text = null) "activity bar holds for (seconds)", () => settings.ActivityBarSeconds, v => settings.ActivityBarSeconds = v, 0, TextSettings.MaxActivityBarSeconds)), - }); + new(string.Empty, null, null), + new("├ STATUS ROW", null, null), + + // One line, `label=value/max; …`, each path allowed `|` alternatives. A list editor would be + // friendlier and is a screen of its own; this is the whole of the setting, typed where it is read, + // and a part that does not parse is dropped rather than refused so a half-typed meter costs nothing. + new("meters", MeterSummary(settings.StatusMeters), null, null, null, + ScreenField.Optional( + "meters", + () => settings.StatusMeters.Count == 0 ? null : StatusMeter.Format(settings.StatusMeters), + v => settings.StatusMeters = StatusMeter.Parse(v))), + }; + + if (sound is not null) + { + rows.AddRange(SoundRows(sound)); + } + + return new OptionsScreen("Text & ANSI", "F7", rows); } + /// + /// F7's SOUND rows: game sound on or off, the three levels, and captions. Drawn on every host, because + /// captions are text and are the one thing a host with no audio device can still do with a sound. + /// + private static IEnumerable SoundRows(SoundSettings sound) + { + yield return new(string.Empty, null, null); + yield return new("├ SOUND", null, null); + yield return new("game sound", null, sound.Enabled, null, + ScreenToggle.Bind(() => sound.Enabled, v => sound.Enabled = v)); + yield return Level("master volume (%)", () => sound.MasterVolume, v => sound.MasterVolume = v); + yield return Level("sound volume (%)", () => sound.SoundVolume, v => sound.SoundVolume = v); + yield return Level("music volume (%)", () => sound.MusicVolume, v => sound.MusicVolume = v); + + // A caption is a line of text for a sound, so it is the one SOUND row that matters with sound off. + yield return new("sound captions", null, sound.Captions, null, + ScreenToggle.Bind(() => sound.Captions, v => sound.Captions = v)); + + static OptionRow Level(string label, Func get, Action set) => + new(label, get().ToString(CultureInfo.InvariantCulture), null, null, null, + ScreenField.Integer(label, get, set, 0, SoundSettings.MaxVolume)); + } + + /// + /// What the meters row shows when it is not being edited: the labels, in order. The full line is long — + /// every alternative path of every meter — and a row clipped at the screen's edge says less than this. + /// + private static string MeterSummary(IReadOnlyList meters) => + meters.Count == 0 ? "none" : string.Join(" · ", meters.Select(m => m.Label)) + " (⏎ edits the paths)"; + /// /// The F8 "Input" screen, reflecting — and writing back to — the app's /// . diff --git a/src/SharpMUTerm.Tui/PackageScreen.cs b/src/SharpMUTerm.Tui/PackageScreen.cs new file mode 100644 index 00000000..19817c6a --- /dev/null +++ b/src/SharpMUTerm.Tui/PackageScreen.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using SharpMUTerm.Core.Packages; +using static SharpMUTerm.Tui.MarkupText; + +namespace SharpMUTerm.Tui; + +/// +/// What the two package surfaces say, as markup rows: the review a player reads before anything is installed, +/// and the list of what is installed. Pure, so a test reads the words the player would. +/// +internal static class PackageScreen +{ + /// + /// The review. It is the consent step, so it leads with what can act as the player: the rules that send + /// commands and the scripts that run, then who it applies to and how much it writes. + /// + internal static List Review(PackageContents contents, IReadOnlyList characters, InstalledPackage? existing, int width) + { + var manifest = contents.Manifest; + var review = contents.Review(); + var lines = new List + { + $"[bold {ScreenPalette.Accent}]{Escape(Elide(manifest.Name, width))}[/] [{ScreenPalette.Label}]{Escape(manifest.Version)}" + + (manifest.Author is { } author ? $" · by {Escape(Elide(author, 40))}" : string.Empty) + "[/]", + }; + + if (existing is not null) + { + lines.Add($"[{ScreenPalette.Label}]replaces version {Escape(existing.Manifest.Version)}, and its sets with it[/]"); + } + + if (!string.IsNullOrWhiteSpace(manifest.Description)) + { + lines.Add($"[{ScreenPalette.Value}]{Escape(Elide(manifest.Description, width))}[/]"); + } + + lines.Add(string.Empty); + lines.Add($"[bold {ScreenPalette.Accent}]WHAT IT ADDS[/]"); + lines.Add(Row("rule sets", Count(review.Sets))); + lines.Add(Row("rules", $"{Of(review.Triggers, "trigger")} · {Of(review.Aliases, "alias", "aliases")} · {Of(review.Macros, "key")} · {Of(review.Timers, "timer")}")); + lines.Add(review.Sending > 0 + ? $" [bold {ScreenPalette.Warn}]{(review.Sending == 1 ? "1 of them sends" : $"{Count(review.Sending)} of them send")} commands as you[/]" + : $" [{ScreenPalette.Label}]none of them sends a command[/]"); + lines.Add(Row("scripts", review.Scripts.Count == 0 + ? "none" + : string.Join(", ", review.Scripts.Select(s => $"{s.Name} ({Size(s.Bytes)})")))); + if (review.Scripts.Count > 0) + { + lines.Add($" [bold {ScreenPalette.Warn}]Lua runs in the sandbox, and can send commands as you[/]"); + } + + lines.Add(Row("media", review.MediaFiles == 0 ? "none" : $"{Of(review.MediaFiles, "file")}, {Size(review.MediaBytes)}")); + if (review.Ignored.Count > 0) + { + lines.Add(Row("left out", $"{Of(review.Ignored.Count, "file")} outside sets/, scripts/ and media/")); + } + + lines.Add(string.Empty); + lines.Add($"[bold {ScreenPalette.Accent}]WHO IT APPLIES TO[/]"); + lines.Add(Row("games", string.Join(", ", manifest.Games))); + lines.Add(Row("characters", characters.Count == 0 ? "none of yours, yet" : string.Join(", ", characters))); + lines.Add(string.Empty); + lines.Add($"[{ScreenPalette.Label}]⏎ install · Esc cancel — nothing is written until you press ⏎[/]"); + return lines; + + string Row(string label, string value) => + $" [{ScreenPalette.Label}]{label.PadRight(11)}[/][{ScreenPalette.Value}]{Escape(Elide(value, Math.Max(10, width - 14)))}[/]"; + } + + /// The installed packages, the selected one's detail, and the keys. + internal static List List( + IReadOnlyList packages, + IReadOnlyCollection disabled, + int selected, + bool confirmingRemove, + Func> characters, + int width) + { + var lines = new List + { + $"[{ScreenPalette.Label}]{Count(packages.Count)} installed · ↑↓ select · Space on/off · c copy sets to yours · Del remove · b browse · Esc close[/]", + string.Empty, + }; + + if (packages.Count == 0) + { + lines.Add($"[{ScreenPalette.Label}]none — b browses the package index; ⌃P ▸ Install package… takes a file or an https link[/]"); + return lines; + } + + for (var i = 0; i < packages.Count; i++) + { + var package = packages[i]; + var on = !disabled.Contains(package.Id); + var marker = i == selected ? $"[bold {ScreenPalette.Accent}]▸[/]" : " "; + var box = on ? "[[x]]" : "[[ ]]"; + lines.Add($"{marker} {box} [{(i == selected ? "bold " : string.Empty)}{ScreenPalette.Value}]{Escape(Elide(package.Manifest.Name, 30))}[/]" + + $" [{ScreenPalette.Label}]{Escape(package.Manifest.Version)} · {Escape(Elide(string.Join(", ", package.Manifest.Games), Math.Max(10, width - 50)))}[/]"); + } + + var current = packages[Math.Clamp(selected, 0, packages.Count - 1)]; + lines.Add(string.Empty); + lines.Add($"[bold {ScreenPalette.Accent}]{Escape(Elide(current.Manifest.Name, width - 4))}[/]"); + if (!string.IsNullOrWhiteSpace(current.Manifest.Description)) + { + lines.Add($" [{ScreenPalette.Value}]{Escape(Elide(current.Manifest.Description, width - 4))}[/]"); + } + + lines.Add($" [{ScreenPalette.Label}]sets[/] [{ScreenPalette.Value}]{Escape(Elide(string.Join(", ", current.Sets.Select(s => s.Name)), width - 14))}[/]"); + var who = characters(current); + lines.Add($" [{ScreenPalette.Label}]characters[/] [{ScreenPalette.Value}]{Escape(Elide(who.Count == 0 ? "none" : string.Join(", ", who), width - 14))}[/]"); + if (confirmingRemove) + { + lines.Add(string.Empty); + lines.Add($"[bold {ScreenPalette.Warn}]Remove {Escape(current.Manifest.Name)} and its files? Del again removes it, any other key keeps it.[/]"); + } + + return lines; + } + + /// The index's packages, the focused world's first, with what is installed beside each. + internal static List Browse( + IReadOnlyList entries, + IReadOnlyList installed, + int selected, + string? world, + int dropped, + int width) + { + var lines = new List + { + $"[{ScreenPalette.Label}]{Of(entries.Count, "package")}" + + (world is null ? string.Empty : $" · {Escape(Elide(world, 30))}'s first") + + " · ↑↓ select · ⏎ review · Esc close[/]", + }; + + if (dropped > 0) + { + lines.Add($"[{ScreenPalette.Warn}]{(dropped == 1 ? "1 entry in the index breaks its rules and is" : $"{Count(dropped)} entries in the index break its rules and are")} not listed[/]"); + } + + lines.Add(string.Empty); + if (entries.Count == 0) + { + lines.Add($"[{ScreenPalette.Label}]the index lists nothing[/]"); + return lines; + } + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + var have = installed.FirstOrDefault(p => p.Id == entry.Id); + var state = have is null ? string.Empty + : have.Manifest.Version == entry.Version ? " · installed" + : $" · installed {Escape(have.Manifest.Version)}, update"; + var marker = i == selected ? $"[bold {ScreenPalette.Accent}]▸[/]" : " "; + lines.Add($"{marker} [{(i == selected ? "bold " : string.Empty)}{ScreenPalette.Value}]{Escape(Elide(entry.Name, 30))}[/]" + + $" [{ScreenPalette.Label}]{Escape(entry.Version)} · {Escape(Elide(string.Join(", ", entry.Games), Math.Max(10, width - 60)))}{state}[/]"); + } + + var current = entries[Math.Clamp(selected, 0, entries.Count - 1)]; + lines.Add(string.Empty); + lines.Add($"[bold {ScreenPalette.Accent}]{Escape(Elide(current.Name, width - 4))}[/]" + + (current.Author is { } author ? $" [{ScreenPalette.Label}]by {Escape(Elide(author, 40))}[/]" : string.Empty)); + if (!string.IsNullOrWhiteSpace(current.Description)) + { + lines.Add($" [{ScreenPalette.Value}]{Escape(Elide(current.Description, width - 4))}[/]"); + } + + lines.Add($" [{ScreenPalette.Label}]from[/] [{ScreenPalette.Value}]{Escape(Elide(current.Url, width - 9))}[/]"); + return lines; + } + + internal static string Of(int n, string one, string? many = null) => + n == 1 ? $"1 {one}" : $"{Count(n)} {many ?? one + "s"}"; + + internal static string Count(int n) => n.ToString(CultureInfo.InvariantCulture); + + internal static string Size(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:0.#} KB", + _ => $"{bytes / (1024.0 * 1024):0.#} MB", + }; + + private static string Elide(string text, int width) => + text.Length <= width ? text : width <= 1 ? "…" : text[..(width - 1)] + "…"; +} diff --git a/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs b/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs index b2302938..9733e5f9 100644 --- a/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs +++ b/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs @@ -114,6 +114,12 @@ internal interface IPointerPresenter /// A right-click asked for a menu at a cell. An empty list is never passed. void ShowMenu(System.Drawing.Point cell, IReadOnlyList items); + /// + /// Whether draws anything. Where it does not, the client opens its own chooser for + /// the one menu a server can ask for, an MXP <SEND> with several commands. + /// + bool DrawsMenus { get; } + /// The panes' scroll positions, whenever one of them changes. void Scrollbars(IReadOnlyList bars); } diff --git a/src/SharpMUTerm.Tui/Pointer/SharpMUTermApp.Pointer.cs b/src/SharpMUTerm.Tui/Pointer/SharpMUTermApp.Pointer.cs index 64999643..83641060 100644 --- a/src/SharpMUTerm.Tui/Pointer/SharpMUTermApp.Pointer.cs +++ b/src/SharpMUTerm.Tui/Pointer/SharpMUTermApp.Pointer.cs @@ -114,6 +114,15 @@ private void OnPointerFrame(List flags, System.Drawing.Point point) if (flags.Contains(MouseFlags.Button3Pressed)) { + // A host that draws no menus still gets a server's menu, as the client's own chooser. + if (_pointer is not { DrawsMenus: true } + && TargetAt(point) is { Kind: PointerKind.Link } link + && LinkOf(link) is { Payload.Choices: not null } menu) + { + ShowSendMenu(menu.WindowId, menu.Payload); + return; + } + ShowPointerMenu(point); return; } @@ -335,6 +344,9 @@ private string TabTooltip(string windowId, bool closable) { switch (LinkPayload.Parse(payload)) { + case { Choices: { } choices } menu: + var verb = menu.Action == LinkAction.Prompt ? "Puts on the command line" : "Sends"; + return $"{verb}:\n{Snippet(choices[0].Command)}\nright-click for {choices.Count - 1} more"; case (LinkAction.Send, var command): return WindowSession(windowId) is { } session ? $"Sends to {SessionTitle(session)}:\n{Snippet(command)}" @@ -451,11 +463,31 @@ internal IReadOnlyList PointerMenu(System.Drawing.Point point) return Tidy(items); } + /// The window and payload of a link target, read back out of its key. + private static (string WindowId, string Url, LinkPayload Payload)? LinkOf(PointerTarget target) + { + var parts = target.Key.Split(':', 4); + return parts.Length == 4 && parts[0] == "link" ? (parts[1], parts[3], LinkPayload.Parse(parts[3])) : null; + } + private void AddLinkItems(List items, PointerTarget target) { var parts = target.Key.Split(':', 4); var (windowId, payload) = (parts[1], parts[3]); - var (action, value) = LinkPayload.Parse(payload); + var parsed = LinkPayload.Parse(payload); + if (parsed.Choices is { } choices) + { + // A server's menu: each of its commands, by its label, in its order. + foreach (var choice in choices) + { + items.Add(new PointerMenuItem(Snippet(choice.Label), choice == choices[0] ? "click" : null, + () => OnUiThread(() => RunSendChoice(windowId, parsed.Action, choice.Command)))); + } + + return; + } + + var (action, value) = parsed; var label = action switch { LinkAction.Send => "Send it", diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index 34964bae..fc4d74be 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -2,6 +2,9 @@ using SharpMUTerm.Core.Theming; using Microsoft.Extensions.Logging; using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Packages; using SharpMUTerm.Core.Telnet.Mssp; using SharpMUTerm.Core.Text; using SharpMUTerm.Graphics; @@ -95,6 +98,17 @@ private static int Main(string[] args) Environment.Exit(0); } + if (args.Contains(PlainHost.Flag)) + { + // The input pump keeps the real stdin; the framework is handed an empty one, or it would read the + // same lines behind a modal of its own. + var stdin = Console.In; + var stdout = Console.Out; + Console.SetIn(TextReader.Null); + var plain = PlainHost.Create(capabilities, stdout); + return RunLive(args, config, loadNotices, plain, app => PlainHost.PumpInput(stdin, (HeadlessConsoleDriver)plain.Driver, app)); + } + return RunLive(args, config, loadNotices, ClientHost.Terminal(capabilities)); } @@ -108,7 +122,8 @@ internal static int RunLive( string[] args, AppConfiguration config, IReadOnlyList loadNotices, - ClientHost host) + ClientHost host, + Action? ready = null) { // What this launch connects: the command line's host if one was typed, else whatever is marked // `at start` on F5, else nothing at all. The precedence lives in Core (StartupConnections) so it @@ -169,6 +184,19 @@ internal static int RunLive( loadLogger.LogWarning("{Notice}", msspProblem); } + // Lua files are read from /scripts and nowhere else (SessionScripts.Resolve), so a trigger + // set naming a path cannot make the client read anything outside it. Created here so a new user has + // somewhere obvious to put a file; a failure leaves the folder missing, and each load then says so. + var scriptsRoot = Path.Combine(Path.GetDirectoryName(ConfigurationStore.DefaultPath)!, "scripts"); + try + { + Directory.CreateDirectory(scriptsRoot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + loadLogger.LogWarning("Could not create the scripts folder {Folder}: {Reason}", scriptsRoot, ex.Message); + } + var liveApp = new SharpMUTermApp( config, host, @@ -176,7 +204,14 @@ internal static int RunLive( save: saved => ConfigurationStore.Save(ConfigurationStore.DefaultPath, saved), logRoot: logRoot, restore: restore, - mssp: mssp); + mssp: mssp, + scriptsRoot: scriptsRoot, + maps: new MapStore(MapStore.PathFor(ConfigurationStore.DefaultPath)) { Logger = diagnostics.For("SharpMUTerm.Map") }, + media: new MediaCache(MediaCache.DefaultRoot), + packages: new PackageStore(PackageStore.PathFor(ConfigurationStore.DefaultPath)) { Logger = diagnostics.For("SharpMUTerm.Packages") }, + scriptStoreRoot: Path.Combine(Path.GetDirectoryName(ConfigurationStore.DefaultPath)!, "script-data"), + spelling: () => OpenDictionary(loadLogger)); + ready?.Invoke(liveApp); int exitCode; try { @@ -308,4 +343,20 @@ internal static string UsageText return text.ToString(); } } + + /// + /// The composer's dictionary: dictionaries/ beside config.json first, then the system's, in the + /// locale's language. Words added with ⌥W go to dictionaries/user-words.txt. + /// + private static SpellChecker? OpenDictionary(ILogger logger) + { + var own = Path.Combine(Path.GetDirectoryName(ConfigurationStore.DefaultPath)!, "dictionaries"); + var checker = SpellChecker.Find(SpellChecker.SearchFolders(own), SpellChecker.PreferredLanguage(), Path.Combine(own, "user-words.txt")); + if (checker is null) + { + logger.LogInformation("No Hunspell dictionary found; put an .aff/.dic pair in {Folder} for spelling in the composer", own); + } + + return checker; + } } diff --git a/src/SharpMUTerm.Tui/RailRenderer.cs b/src/SharpMUTerm.Tui/RailRenderer.cs index 3534fbc9..1da5ddc2 100644 --- a/src/SharpMUTerm.Tui/RailRenderer.cs +++ b/src/SharpMUTerm.Tui/RailRenderer.cs @@ -132,7 +132,9 @@ public static List RenderCollapsed(IReadOnlyList rows, ChromeIn private static string Character(RailRow row, bool reserve, ChromeInk ink) { var marker = row.Active ? "[bold]▸[/]" : " "; - var dot = row.Connected ? "●" : "○"; + // A member of the send group wears a diamond in the dot's cell: the same width, so joining the group + // moves no column and re-announces no pane size. + var dot = row.InGroup ? (row.Connected ? "◆" : "◇") : row.Connected ? "●" : "○"; var name = row.Active ? $"[bold]{Escape(row.Label)}[/]" : Escape(row.Label); return $"{Indent(row)}{ChordField(row.Chord, reserve)}" + Link(row, $"{marker} [{Accent(row, ink)}]{dot}[/] {name}{UnreadField(row.Unread, ink)}"); diff --git a/src/SharpMUTerm.Tui/ScreenField.cs b/src/SharpMUTerm.Tui/ScreenField.cs index 7da23a2d..609ff6ee 100644 --- a/src/SharpMUTerm.Tui/ScreenField.cs +++ b/src/SharpMUTerm.Tui/ScreenField.cs @@ -572,6 +572,19 @@ internal static ScreenField Pattern(string label, Func get, ActionA that may be blank, held as null when it is: a pattern a world need not have. + internal static ScreenField OptionalPattern(string label, Func get, Action set) + { + ArgumentNullException.ThrowIfNull(get); + ArgumentNullException.ThrowIfNull(set); + + return new ScreenField( + label, + () => get() ?? string.Empty, + value => string.IsNullOrWhiteSpace(value) ? null : ValidatePattern(value), + value => set(string.IsNullOrWhiteSpace(value) ? null : value)); + } + /// /// Text that may hold newlines (an alias expansion is one command per line), edited on one row /// with the breaks written \n — so a multi-line value is still editable without the diff --git a/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj b/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj index e97ae327..2926bfa8 100644 --- a/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj +++ b/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj @@ -68,6 +68,7 @@ + diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Announce.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Announce.cs new file mode 100644 index 00000000..785349ab --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Announce.cs @@ -0,0 +1,49 @@ +using SharpMUTerm.Core.Accessibility; + +namespace SharpMUTerm.Tui; + +/// +/// What a screen reader is told: each line of output as it arrives, the client's own messages, and the window +/// that has come to the front. Sent to , so only a host that has one (the +/// window's live region, --plain) pays anything for it. A window can be kept quiet from ⌃P. +/// +internal sealed partial class SharpMUTermApp +{ + /// Windows whose output is not announced. In memory: a run's choice, like the send group. + private readonly HashSet _quietWindows = new(StringComparer.Ordinal); + + /// Whether a window's output is announced, for the tests and the ⌃P entry. + internal bool IsAnnounced(string windowId) => Host.Announcer is not null && !_quietWindows.Contains(windowId); + + private void AnnounceLine(string windowId, string text) + { + if (Host.Announcer is { } announcer && !_quietWindows.Contains(windowId) && text.Trim().Length > 0) + { + announcer.Announce(text, AnnouncementKind.Output); + } + } + + private void AnnounceNotice(string text) => Host.Announcer?.Announce(text, AnnouncementKind.Notice); + + private void AnnounceFocus(string windowId) + { + if (Host.Announcer is { } announcer) + { + announcer.Announce(WindowTitle(windowId), AnnouncementKind.Focus); + } + } + + /// ⌃P ▸ Stop reading this window aloud and back. + private void ToggleAnnounced() + { + var windowId = ActiveWindowId(); + if (_quietWindows.Remove(windowId)) + { + Notice($"reading {Snippet(WindowTitle(windowId))} aloud", Core.Diagnostics.MessageSeverity.Info); + return; + } + + _quietWindows.Add(windowId); + Notice($"{Snippet(WindowTitle(windowId))} is no longer read aloud", Core.Diagnostics.MessageSeverity.Info); + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Completion.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Completion.cs new file mode 100644 index 00000000..ef690a80 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Completion.cs @@ -0,0 +1,44 @@ +namespace SharpMUTerm.Tui; + +/// +/// ⇥ on the command line completes the word before the caret from what the connection the bar sends to has +/// seen (), and ⇥ again cycles through the rest, back round to what +/// was typed. +/// +internal sealed partial class SharpMUTermApp +{ + /// + /// The cycle ⇥ is in: the text before the word, the choices (what was typed last), which one is shown, + /// and the whole text as it was left. Any edit makes the text differ, which ends the cycle. + /// + private (string Before, IReadOnlyList Choices, int Index, string Placed)? _completion; + + private string? CompleteWord(string text) + { + if (_completion is { } cycle && string.Equals(cycle.Placed, text, StringComparison.Ordinal)) + { + var next = (cycle.Index + 1) % cycle.Choices.Count; + var placed = cycle.Before + cycle.Choices[next]; + _completion = cycle with { Index = next, Placed = placed }; + return placed; + } + + _completion = null; + var start = text.Length; + while (start > 0 && (char.IsLetterOrDigit(text[start - 1]) || text[start - 1] is '_' or '\'' or '-')) + { + start--; + } + + var prefix = text[start..]; + if (prefix.Length == 0 || SendTarget()?.Completions.Candidates(prefix) is not { Count: > 0 } found) + { + return null; + } + + var choices = found.Append(prefix).ToList(); + var first = text[..start] + choices[0]; + _completion = (text[..start], choices, 0, first); + return first; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Grab.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Grab.cs new file mode 100644 index 00000000..ddd9b15a --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Grab.cs @@ -0,0 +1,49 @@ +using SharpMUTerm.Core.Diagnostics; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Tui; + +/// +/// A MUSH's edit grab: a line starting with the world's grab prefix (AresMUSH's FugueEdit >) is not +/// shown, and its text goes on the command line for the player to change and send. Nothing is ever sent for +/// them, because another player can page a line that starts with the prefix. +/// +internal sealed partial class SharpMUTermApp +{ + /// Grabs for windows not in front, by window id, placed when the window is next brought forward. + private readonly Dictionary _pendingGrabs = new(StringComparer.Ordinal); + + /// + /// Puts a grab on the command line of the window it came from: now, when that window is in front, and + /// otherwise when it is next brought forward, so a grab never lands on another character's bar. + /// + private void ReceiveGrab(WorldSession session, string windowId, string text) + { + if (ReferenceEquals(WindowSession(ActiveWindowId()), session)) + { + PlaceGrab(ActiveBar(), text, session.SessionKey); + Notice("text from the game is on the command line — check it before ⏎", MessageSeverity.Info); + return; + } + + _pendingGrabs[windowId] = text; + Notice($"text from {Snippet(SessionTitle(session))} is waiting for that window's command line", MessageSeverity.Info); + } + + /// + /// Replaces the bar's text with the grab. What was there goes into history first, so ⌥↑ brings back a line + /// that was being typed; through Add, so a connect line still meets the secret gate. + /// + private void PlaceGrab(InputBarControl bar, string text, string? owner = null) + { + if (bar.Text.Trim().Length > 0) + { + (owner is null ? HistoryFor(BarKind(bar)) : HistoryFor(owner, BarKind(bar))).Add(bar.Text); + } + + bar.SetAndNotify(text); + } + + /// The grab waiting for , for the tests. + internal string? PendingGrab(string windowId) => _pendingGrabs.GetValueOrDefault(windowId); +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.MooEdit.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.MooEdit.cs new file mode 100644 index 00000000..56070887 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.MooEdit.cs @@ -0,0 +1,89 @@ +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Diagnostics; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Telnet; +using static SharpMUTerm.Tui.MarkupText; + +namespace SharpMUTerm.Tui; + +/// +/// Editing a MOO's verbs, properties and notes in the composer (MCP dns-org-mud-moo-simpleedit). The MOO +/// hands over a buffer, F1 edits it, and ⌃S saves it back as lines. +/// +internal sealed partial class SharpMUTermApp +{ + /// + /// The buffers each character's MOO handed over and nobody has saved yet, by session key. One each: a MOO + /// that sends another edit means the newer one, as @edit typed again does. Held apart from the post + /// drafts so an edit arriving never replaces a post being written. + /// + private readonly Dictionary _mooEdits = new(StringComparer.Ordinal); + + /// The reserved width of the MOO status cell. + internal const int MooStatusCellWidth = 28; + + /// The edit held for , for the tests. + internal MooEdit? HeldMooEdit(string sessionKey) => _mooEdits.TryGetValue(sessionKey, out var held) ? held.Edit : null; + + /// + /// A MOO handed over a buffer. It opens in the composer straight away when that character's window is the + /// one in front and nothing else is open, since @edit was just typed there; otherwise it is held and + /// the notice says F1 opens it, so a modal never lands over another window or another surface. + /// + private void ReceiveMooEdit(WorldSession session, MooEdit edit) + { + _mooEdits[session.SessionKey] = new ComposeResult(string.Join('\n', edit.Lines), ComposeEscaping.AsTyped, edit); + if (!AnyOverlayOpen && ReferenceEquals(WindowSession(ActiveWindowId()), session)) + { + ToggleComposer(); + return; + } + + Notice($"{Snippet(SessionTitle(session))}'s MOO sent {Snippet(edit.Name)} to edit · F1 in that window opens it", MessageSeverity.Info); + } + + /// + /// Saves an edit back to the MOO: the buffer's lines as typed. The window closes first, which keeps the + /// buffer as the held edit, and the edit is let go only once the MOO has it — a save that fails keeps it. + /// + private void SaveMooEdit(WorldSession session, MooEdit edit, string body) + { + _composer.Close(); + var key = session.SessionKey; + var lines = MooEditing.LinesOf(body); + _ = SaveAsync(); + + async Task SaveAsync() + { + var sent = await session.SendMooEditAsync(edit, lines).ConfigureAwait(false); + OnUiThread(() => + { + if (!sent) + { + RefuseCommand($"{Snippet(edit.Name)} was not saved — the MOO is not taking edits on this connection; F1 keeps it"); + return; + } + + if (_mooEdits.TryGetValue(key, out var held) && ReferenceEquals(held.Edit, edit)) + { + _mooEdits.Remove(key); + } + + Notice($"saved {Snippet(edit.Name)}", MessageSeverity.Info); + }); + } + } + + /// » status, elided and padded to . The text is the server's. + internal static string MooStatusCell(string status) + { + var text = new string([.. status.Select(c => char.IsControl(c) ? ' ' : c)]).Trim(); + var room = MooStatusCellWidth - 2; + if (text.Length > room) + { + text = text[..(room - 1)] + "…"; + } + + return $"[dim]» {Escape(text.PadRight(room))}[/]"; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Packages.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Packages.cs new file mode 100644 index 00000000..570cf5e2 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Packages.cs @@ -0,0 +1,560 @@ +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Diagnostics; +using SharpMUTerm.Core.Packages; + +namespace SharpMUTerm.Tui; + +/// +/// Packages: installing one from a file or an https link through the review, and the Packages screen (F10) +/// that lists, switches off, copies from and removes them. +/// +internal sealed partial class SharpMUTermApp +{ + /// Installed packages; memory-only unless Program hands it a folder. + private readonly PackageStore _packages; + + private readonly ListOverlay _packageView; + + /// The media folders this app added to the sound cache, so a change can take them back out. + private readonly List _packageMedia = new(); + + private int _packageSelected; + private bool _confirmRemove; + + /// How an https package is downloaded; a test supplies one, the client uses . + internal Func>? PackageFetch { get; set; } + + internal ListOverlay PackageView => _packageView; + + internal PackageStore Packages => _packages; + + /// Reads the installed packages and hands their sets and media to the rest of the client. + private void LoadPackages() + { + _packages.Load(); + SyncPackages(); + } + + private void SyncPackages() + { + _config.Packages = _packages.Installed; + foreach (var folder in _packageMedia) + { + _media.LocalFolders.Remove(folder); + } + + _packageMedia.Clear(); + foreach (var folder in _packages.MediaFolders) + { + if (!_config.DisabledPackages.Contains(Path.GetFileName(Path.GetDirectoryName(folder)!))) + { + _media.LocalFolders.Add(folder); + _packageMedia.Add(folder); + } + } + } + + /// /package opens the Packages screen; /package install <file or https link> installs one. + private void PackageCommand(string argument) + { + if (argument.Length == 0) + { + ShowPackages(); + return; + } + + if (argument.StartsWith("install", StringComparison.OrdinalIgnoreCase)) + { + InstallPackage(argument[7..].Trim()); + return; + } + + if (argument.Equals("browse", StringComparison.OrdinalIgnoreCase)) + { + BrowsePackages(); + return; + } + + RefuseCommand("/package, /package browse, or /package install "); + } + + /// Reads a package from a path or an https link and opens its review. Nothing is written here. + internal void InstallPackage(string source) + { + source = source.Trim().Trim('"'); + if (source.Length == 0) + { + RefuseCommand("/package install needs a file or an https link"); + return; + } + + if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + RefuseCommand("packages are fetched over https only"); + return; + } + + if (source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) + { + RefuseCommand("that is not a link"); + return; + } + + Fetch(uri, "package", bytes => ReviewPackage(bytes)); + return; + } + + byte[] archive; + try + { + var info = new FileInfo(Path.GetFullPath(source)); + if (!info.Exists) + { + RefuseCommand($"no file at {Snippet(source)}"); + return; + } + + if (info.Length > PackageArchive.MaxArchiveBytes) + { + RefuseCommand("that file is too large to be a package"); + return; + } + + archive = File.ReadAllBytes(info.FullName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + RefuseCommand($"could not read {Snippet(source)}: {ex.Message}"); + return; + } + + ReviewPackage(archive); + } + + /// + /// Opens the review for an archive, or says why it is not a package. From an index, the archive must be + /// the one names — its SHA-256, id and version — before anything is shown. + /// + internal void ReviewPackage(byte[] archive, PackageIndexEntry? listed = null) + { + if (listed?.Verify(archive, inside: null) is { } mismatch) + { + RefuseCommand($"not installed: {mismatch}"); + return; + } + + var (contents, problem) = PackageArchive.Read(archive); + if (contents is null) + { + RefuseCommand($"not installed: {problem}"); + return; + } + + if (listed?.Verify(archive, contents.Manifest) is { } different) + { + RefuseCommand($"not installed: {different}"); + return; + } + + if (AnyOverlayOpen && !_packageView.IsOpen) + { + RefuseCommand($"close {OpenOverlayName()} first"); + return; + } + + var existing = _packages.Find(contents.Manifest.Id); + _packageView.Open( + "review", + existing is null ? "Install package" : "Update package", + width => PackageScreen.Review(contents, CharactersFor(contents.Manifest), existing, width), + key => + { + if (key.Key != ConsoleKey.Enter) + { + return true; + } + + if (_packages.Install(contents) is { } failed) + { + RefuseCommand($"not installed: {failed}"); + return false; + } + + SyncPackages(); + SaveConfiguration(); + Notice( + $"Installed {contents.Manifest.Name} {contents.Manifest.Version} · F10 lists your packages", + MessageSeverity.Info, + key: "package"); + return false; + }); + } + + /// + /// Downloads off the UI thread and hands the bytes back on it, or says why not. + /// + private void Fetch(Uri uri, string what, Action then) + { + Notice($"Fetching {Snippet(uri.Host)}…", MessageSeverity.Info, key: "package"); + var fetch = PackageFetch ?? DownloadPackageAsync; + _ = Task.Run(async () => + { + byte[]? bytes = null; + string? problem = null; + try + { + bytes = await fetch(uri, CancellationToken.None).ConfigureAwait(false); + problem = bytes is null ? "the download failed or was too large" : null; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException) + { + problem = ex.Message; + } + + OnUiThread(() => + { + if (bytes is null) + { + RefuseCommand($"{what} not fetched: {problem}"); + return; + } + + then(bytes); + }); + }); + } + + /// + /// Browse packages: fetches the index — only now, never at start — and lists it with the focused world's + /// packages first. ⏎ fetches one, checks it against the index's SHA-256 and opens the review. + /// + internal void BrowsePackages() + { + if (ComposerIsInTheWay("the packages") || (AnyOverlayOpen && !_packageView.IsOpen && Refuse($"close {OpenOverlayName()} first"))) + { + return; + } + + if (!Uri.TryCreate(_config.PackageIndexUrl, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + RefuseCommand("the package index is not an https link (packageIndexUrl in config.json)"); + return; + } + + Fetch(uri, "package index", bytes => + { + if (bytes.Length > PackageIndex.MaxIndexBytes) + { + RefuseCommand("the package index is too large"); + return; + } + + var (index, problem) = PackageIndex.Parse(System.Text.Encoding.UTF8.GetString(bytes)); + if (index is null) + { + RefuseCommand($"package index not read: {problem}"); + return; + } + + ShowIndex(index); + }); + } + + /// + /// The world of the focused window: its session's, else its recorded owner's — so a disconnected + /// character's window still names its game. Never _active. + /// + private WorldDefinition? FocusedWorld() + { + if (SendTarget()?.World is { } live) + { + return live; + } + + var owner = _workspace.FindWindow(ActiveWindowId())?.SessionKey; + return owner is null + ? null + : _config.Worlds.FirstOrDefault(w => w.Characters.Any(c => string.Equals($"{w.Name}.{c.Name}", owner, StringComparison.Ordinal))); + } + + private void ShowIndex(PackageIndex index) + { + var world = FocusedWorld(); + var entries = index.For(world?.Host, world?.Port ?? 0); + _packageSelected = 0; + _packageView.Open( + "browse", + "Browse packages", + width => PackageScreen.Browse(entries, _packages.Installed, _packageSelected, world?.Name, index.Dropped, width), + key => + { + switch (key.Key) + { + case ConsoleKey.UpArrow: + _packageSelected = Math.Max(0, _packageSelected - 1); + return true; + case ConsoleKey.DownArrow: + _packageSelected = Math.Min(Math.Max(0, entries.Count - 1), _packageSelected + 1); + return true; + case ConsoleKey.Enter when entries.Count > 0: + var entry = entries[Math.Clamp(_packageSelected, 0, entries.Count - 1)]; + Fetch(new Uri(entry.Url), "package", bytes => ReviewPackage(bytes, entry)); + return false; + default: + return true; + } + }); + } + + /// The Packages screen, or closes it. + internal void ShowPackages() + { + if (_packageView.IsOpen) + { + _packageView.Close(); + return; + } + + if (ComposerIsInTheWay("the packages") || (AnyOverlayOpen && Refuse($"close {OpenOverlayName()} first"))) + { + return; + } + + _packageSelected = 0; + _confirmRemove = false; + _packageView.Open( + "list", + "Packages", + width => PackageScreen.List( + _packages.Installed, _config.DisabledPackages, _packageSelected, _confirmRemove, p => CharactersFor(p.Manifest), width), + OnPackageKey); + } + + private bool Refuse(string reason) + { + RefuseCommand(reason); + return true; + } + + private bool OnPackageKey(ConsoleKeyInfo key) + { + var installed = _packages.Installed; + var confirming = _confirmRemove; + _confirmRemove = false; + if (key.Key == ConsoleKey.B) + { + BrowsePackages(); + return false; + } + + if (installed.Count == 0) + { + return key.Key is not (ConsoleKey.F10 or ConsoleKey.Enter); + } + + var current = installed[Math.Clamp(_packageSelected, 0, installed.Count - 1)]; + switch (key.Key) + { + case ConsoleKey.UpArrow: + _packageSelected = Math.Max(0, _packageSelected - 1); + break; + case ConsoleKey.DownArrow: + _packageSelected = Math.Min(installed.Count - 1, _packageSelected + 1); + break; + case ConsoleKey.Spacebar: + if (!_config.DisabledPackages.Remove(current.Id)) + { + _config.DisabledPackages.Add(current.Id); + } + + SyncPackages(); + SaveConfiguration(); + break; + case ConsoleKey.C: + CopyPackageSets(current); + break; + case ConsoleKey.Delete or ConsoleKey.Backspace when confirming: + if (_packages.Remove(current.Id) is { } problem) + { + RefuseCommand(problem); + } + + _config.DisabledPackages.Remove(current.Id); + _packageSelected = Math.Max(0, Math.Min(_packageSelected, _packages.Installed.Count - 1)); + SyncPackages(); + SaveConfiguration(); + Notice($"Removed {current.Manifest.Name}.", MessageSeverity.Info, key: "package"); + break; + case ConsoleKey.Delete or ConsoleKey.Backspace: + _confirmRemove = true; + break; + case ConsoleKey.F10: + return false; + } + + return true; + } + + /// + /// Copies a package's sets into the player's own, where F2–F6 can edit them and an update will not + /// replace them. A name already taken gets (copy). + /// + private void CopyPackageSets(InstalledPackage package) + { + foreach (var set in package.Sets) + { + var copy = JsonSerializer.Deserialize( + JsonSerializer.SerializeToUtf8Bytes(set, ConfigurationStore.SerializerOptions), ConfigurationStore.SerializerOptions)!; + var name = set.Name; + for (var n = 1; _config.TriggerSets.Any(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)); n++) + { + name = n == 1 ? $"{set.Name} (copy)" : $"{set.Name} (copy {n})"; + } + + copy.Name = name; + _config.TriggerSets.Add(copy); + } + + SaveConfiguration(); + Notice( + $"Copied {package.Sets.Count} set(s) to yours; assign them on F5, and switch the package off (Space) to use only your copy.", + MessageSeverity.Info, + key: "package"); + } + + /// The characters a package would apply to: everyone on a world it is for. + private IReadOnlyList CharactersFor(PackageManifest manifest) => + _config.Worlds + .Where(w => manifest.IsFor(w.Host, w.Port)) + .SelectMany(w => w.Characters.Select(c => $"{c.Name} ({w.Name})")) + .ToList(); + + /// + /// The snapshot's packages, built as archives and read through like any + /// other, into the store the snapshot was given — which has no folder, so nothing is written. + /// + internal static byte[] DemoPackage(string id, string name, string version, string games, string set, string description) + { + var manifest = $$""" + {"id":"{{id}}","name":"{{name}}","version":"{{version}}","author":"the Aetherfall builders", + "description":"{{description}}","games":[{{games}}]} + """; + using var buffer = new MemoryStream(); + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (entry, text) in new[] + { + ("package.json", manifest), + ("sets/main.json", set), + ("scripts/status.lua", "-- keeps the status gauges current\nfunction on_vitals() end\n"), + ("media/bell.ogg", "OggS"), + ("README.md", "read me"), + }) + { + using var stream = zip.CreateEntry(entry).Open(); + stream.Write(Encoding.UTF8.GetBytes(text)); + } + } + + return buffer.ToArray(); + } + + private static readonly byte[] DemoBasics = DemoPackage( + "aetherfall.basics", + "Aetherfall basics", + "1.2.0", + "\"aetherfall.mux:4201\"", + """ + {"name":"Aetherfall basics","triggers":[ + {"name":"low health","pattern":"^Your wounds","actions":{"sendResponse":"quaff red"}}, + {"name":"tells","pattern":"^\\w+ tells you","actions":{"spawnTarget":"Tells"}}], + "aliases":[{"name":"k","pattern":"^k (.+)$","substitution":"kill $1"}], + "scriptFiles":["scripts/status.lua"]} + """, + "Health warnings, a tells window and the status gauges."); + + private void LoadDemoPackages(string view) + { + if (view == "package-review") + { + ReviewPackage(DemoBasics); + return; + } + + if (view == "package-browse") + { + // Installed at an older version, so the list shows an update beside it. + _packages.Install(PackageArchive.Read(DemoPackage( + "aetherfall.basics", "Aetherfall basics", "1.1.0", "\"aetherfall.mux:4201\"", """{"name":"Aetherfall basics"}""", "")).Contents!); + SyncPackages(); + var hash = new string('0', 64); + var (index, _) = PackageIndex.Parse($$""" + {"packages":[ + {"id":"mush.colours","name":"MUSH colour names","version":"0.3.1","games":["*"], + "description":"Highlights pages and channel names on any game.", + "url":"https://packages.example/mush-colours-0.3.1.zip","sha256":"{{hash}}"}, + {"id":"grapevine.channels","name":"Grapevine channels","version":"2.0.0","games":["grapevine.haus"], + "url":"https://packages.example/grapevine-channels-2.0.0.zip","sha256":"{{hash}}"}, + {"id":"aetherfall.basics","name":"Aetherfall basics","version":"1.2.0","author":"the Aetherfall builders", + "games":["aetherfall.mux:4201"],"description":"Health warnings, a tells window and the status gauges.", + "url":"https://packages.example/aetherfall-basics-1.2.0.zip","sha256":"{{hash}}"}, + {"id":"aetherfall.mapper","name":"Aetherfall mapper rooms","version":"0.9.0","games":["aetherfall.mux"], + "url":"https://packages.example/aetherfall-mapper-0.9.0.zip","sha256":"{{hash}}"}, + {"id":"bad","name":"Bad","version":"1","games":["*"],"url":"http://x","sha256":"x"}]} + """); + ShowIndex(index!); + return; + } + + _packages.Install(PackageArchive.Read(DemoBasics).Contents!); + _packages.Install(PackageArchive.Read(DemoPackage( + "mush.colours", + "MUSH colour names", + "0.3.1", + "\"*\"", + """{"name":"MUSH colours","triggers":[{"name":"pages","pattern":"^From afar","actions":{"spawnTarget":"Pages"}}]}""", + "Highlights pages and channel names on any game.")).Contents!); + _config.DisabledPackages.Add("mush.colours"); + SyncPackages(); + ShowPackages(); + } + + private static readonly HttpClient PackageClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + + /// Downloads an archive over https, stopping at the size cap. + private static async Task DownloadPackageAsync(Uri uri, CancellationToken token) + { + if (uri.Scheme != Uri.UriSchemeHttps) + { + return null; + } + + using var response = await PackageClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > PackageArchive.MaxArchiveBytes) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await stream.ReadAsync(chunk, token).ConfigureAwait(false)) > 0) + { + if (buffer.Length + read > PackageArchive.MaxArchiveBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + return buffer.ToArray(); + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Presence.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Presence.cs new file mode 100644 index 00000000..c3210148 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Presence.cs @@ -0,0 +1,54 @@ +using SharpMUTerm.Core.Presence; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Tui; + +/// +/// Discord presence: what the game in front says about itself (External.Discord), shown through the host's +/// . Only the world whose window is in front is shown, only when that world +/// allows it (F5 ▸ discord), and only while it is connected; anything else clears it. +/// +internal sealed partial class SharpMUTermApp +{ + private IPresence? _presence; + private bool _presenceOpened; + private GamePresence? _shownPresence; + + /// What was last handed to the host, for the tests. + internal GamePresence? ShownPresence => _shownPresence; + + private void WatchPresence(WorldSession session) + { + session.GamePresenceChanged += (_, _) => OnUi(UpdatePresence); + session.StateChanged += (_, _) => OnUi(UpdatePresence); + } + + /// + /// Shows the focused window's game, or clears what is shown. The host is opened the first time there is + /// something to show, so a client whose worlds never opt in never touches Discord. + /// + private void UpdatePresence() + { + var session = SendTarget(); + var wanted = session is { IsConnected: true, World.DiscordPresence: true } ? session.GamePresence : null; + if (wanted == _shownPresence) + { + return; + } + + if (wanted is not null && !_presenceOpened) + { + _presenceOpened = true; + _presence = Host.Presence?.Invoke(); + } + + _shownPresence = wanted; + _presence?.Show(wanted); + } + + private void DisposePresence() + { + _presence?.Dispose(); + _presence = null; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.SendGroup.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.SendGroup.cs new file mode 100644 index 00000000..e450fabb --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.SendGroup.cs @@ -0,0 +1,109 @@ +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Diagnostics; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Tui; + +/// +/// The send group: characters picked on ⌃P, marked ◆ on the rail, and sent one line together by +/// ⌃P ▸ Send the command line to the group. Lua reaches the same sessions by name through +/// world.sendTo. +/// +internal sealed partial class SharpMUTermApp +{ + private readonly SendGroup _sendGroup = new(); + + /// The send group's members, for the tests. + internal IReadOnlyList SendGroupKeys => _sendGroup.Keys; + + /// Handles a group: id; false for any other. + private bool DispatchGroupCommand(string id) + { + if (id.StartsWith(CommandIds.GroupTogglePrefix, StringComparison.Ordinal)) + { + var key = id[CommandIds.GroupTogglePrefix.Length..]; + if (_sessions.Find(key) is null && !_sendGroup.Contains(key)) + { + RefuseCommand($"{key} is not open"); + return true; + } + + Notice( + _sendGroup.Toggle(key) ? $"{key} is in the send group" : $"{key} has left the send group", + MessageSeverity.Info); + RefreshRail(); + return true; + } + + if (id == CommandIds.GroupClear) + { + _sendGroup.Clear(); + RefreshRail(); + return true; + } + + if (id == CommandIds.GroupSend) + { + SendToGroup(); + return true; + } + + return false; + } + + /// + /// Sends what is on the command line to every connected member of the group, once each, as if typed in that + /// character's own window, then clears the line the way ⏎ does. Members that are not connected get nothing + /// and are named. + /// + private void SendToGroup() + { + var bar = ActiveBar(); + var command = bar.Text; + if (command.Trim().Length == 0) + { + RefuseCommand("the command line is empty — type what the group should send first"); + return; + } + + var (reachable, missed) = _sendGroup.Resolve(_sessions.Sessions); + if (reachable.Count == 0) + { + RefuseCommand(_sendGroup.Count == 0 ? "the send group is empty" : "nobody in the send group is connected"); + return; + } + + foreach (var session in reachable) + { + _ = session.SendUserInputAsync(command); + } + + var kind = BarKind(bar); + HistoryFor(kind).Add(command); + bar.SetAndNotify(string.Empty); + var windowId = ActiveWindowId(); + _drafts.Clear(windowId, kind); + _workspace.SetUnsentInput(windowId, AnyBarHasText()); + RefreshTabTitles(); + + var names = string.Join(", ", reachable.Select(s => s.Character?.Name ?? s.SessionKey)); + Notice( + missed.Count == 0 + ? $"sent to {names}" + : $"sent to {names}; not to {string.Join(", ", missed)}, not connected", + missed.Count == 0 ? MessageSeverity.Info : MessageSeverity.Warning); + } + + /// What world.sendTo does: null when the line went, or why it did not. + private string? ScriptSendTo(string name, string command) + { + var (session, refusal) = SendGroup.Find(_sessions.Sessions, name); + if (session is null) + { + return refusal; + } + + _ = session.SendRawAsync(command); + return null; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.SendMenu.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.SendMenu.cs new file mode 100644 index 00000000..b23b5436 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.SendMenu.cs @@ -0,0 +1,97 @@ +using SharpMUTerm.Core.Text; +using static SharpMUTerm.Tui.MarkupText; + +namespace SharpMUTerm.Tui; + +/// +/// An MXP <SEND HREF="a|b|c"> menu. A click sends the first command; a right-click offers them +/// all — in the window's own menu, and in a terminal, which has none, in this chooser. +/// +internal sealed partial class SharpMUTermApp +{ + private readonly ListOverlay _sendMenu; + + private int _sendMenuSelected; + + internal ListOverlay SendMenu => _sendMenu; + + /// Sends, or puts on the command line, one command of a menu, as a click on a one-command link would. + private void RunSendChoice(string windowId, LinkAction action, string command) => + OnLinkClicked(windowId, (action == LinkAction.Prompt ? LinkPayload.PromptScheme : LinkPayload.SendScheme) + command); + + /// The chooser: the menu's labels, ↑↓ and ⏎, or a digit for one of the first nine. + internal void ShowSendMenu(string windowId, LinkPayload menu) + { + if (menu.Choices is not { Count: > 0 } choices || AnyOverlayOpen) + { + return; + } + + _sendMenuSelected = 0; + var to = WindowSession(windowId) is { } session ? SessionTitle(session) : null; + _sendMenu.Open( + "menu", + menu.Action == LinkAction.Prompt ? "Put on the command line" : to is null ? "Send" : $"Send to {Snippet(to)}", + width => SendMenuLines(choices, _sendMenuSelected, width), + key => + { + switch (key.Key) + { + case ConsoleKey.UpArrow: + _sendMenuSelected = Math.Max(0, _sendMenuSelected - 1); + return true; + case ConsoleKey.DownArrow: + _sendMenuSelected = Math.Min(choices.Count - 1, _sendMenuSelected + 1); + return true; + case ConsoleKey.Enter: + RunSendChoice(windowId, menu.Action, choices[_sendMenuSelected].Command); + return false; + case >= ConsoleKey.D1 and <= ConsoleKey.D9 when key.Key - ConsoleKey.D1 < choices.Count: + RunSendChoice(windowId, menu.Action, choices[key.Key - ConsoleKey.D1].Command); + return false; + default: + return true; + } + }, + width: SendMenuLines(choices, 0, int.MaxValue).Max(MarkupText.VisibleLength)); + } + + internal static List SendMenuLines(IReadOnlyList choices, int selected, int width) + { + var lines = new List(); + var column = choices.Max(c => Snippet(c.Label).Length) + 2; + for (var i = 0; i < choices.Count; i++) + { + var key = i < 9 ? $"{i + 1}" : " "; + var mark = i == selected ? $"[bold {ScreenPalette.Accent}]▸[/]" : " "; + var label = Snippet(choices[i].Label); + var command = choices[i].Command == choices[i].Label + ? string.Empty + : $"[{ScreenPalette.Label}]{Escape(Snippet(choices[i].Command))}[/]"; + lines.Add($"{mark} [{ScreenPalette.Label}]{key}[/] [{(i == selected ? "bold " : string.Empty)}{ScreenPalette.Value}]{Escape(label.PadRight(column))}[/]{command}"); + } + + lines.Add(string.Empty); + lines.Add($"[{ScreenPalette.Label}]↑↓ select · ⏎ or a digit sends · Esc cancels[/]"); + return lines; + } + + /// The first cell of a <SEND> menu in 's pane, read off the arranged frame; for the demo and tests. + internal System.Drawing.Point? MenuLinkCell(string windowId) + { + var size = _system.ConsoleDriver.ScreenSize; + for (var y = 0; y < size.Height; y++) + { + for (var x = 0; x < size.Width; x++) + { + var cell = new System.Drawing.Point(x, y); + if (TargetAt(cell) is { Kind: PointerKind.Link } target && LinkOf(target) is { Payload.Choices: not null } link && link.WindowId == windowId) + { + return cell; + } + } + } + + return null; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.Sound.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.Sound.cs new file mode 100644 index 00000000..870ae6b4 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.Sound.cs @@ -0,0 +1,163 @@ +using SharpMUTerm.Core.Diagnostics; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Session; +using static SharpMUTerm.Tui.MarkupText; + +namespace SharpMUTerm.Tui; + +/// +/// Game sound: each connection's , fed by its MCMP commands, played on the host's audio +/// device. The device is opened the first time a game asks for something, never at start, so a client that +/// is never sent a sound never touches the sound system. +/// +internal sealed partial class SharpMUTermApp +{ + /// Where sound files are kept: memory-only (nothing fetched) unless Program hands it a folder. + private readonly MediaCache _media; + + /// Each connection's queue, made on its first media command. + private readonly Dictionary _queues = new(); + + private IAudioOutput? _audio; + + private bool _audioOpened; + + /// ⌃P ▸ Mute sound. For this run only: a mute is for the next few minutes, not a setting. + private bool _muted; + + /// Whether this client can play sound at all: the host has a device to offer. + private bool CanPlaySound => Host.Audio is not null; + + /// The status row's mute cell while muted. + internal const string MutedCell = "♪ muted"; + + /// Test seam: the queue a session's media goes to, or null before its first command. + internal MediaQueue? MediaQueueFor(WorldSession session) => _queues.GetValueOrDefault(session); + + internal bool Muted => _muted; + + private void StartSound(WorldSession session) + { + session.PlaysMedia = CanPlaySound && _config.Sound.Enabled; + session.MediaReceived += (_, command) => OnUiThread(() => OnMedia(session, command)); + session.StateChanged += (_, e) => + { + if (e.State is ConnectionState.Disconnected or ConnectionState.Faulted) + { + OnUiThread(() => MediaQueueFor(session)?.Stop(MediaFilter.All)); + } + }; + } + + private void OnMedia(WorldSession session, MediaCommand command) + { + if (QueueFor(session) is { } queue) + { + queue.Apply(command); + return; + } + + // Nothing plays — no device, or sound is off — so a caption is the only thing a sound can be. + if (command is MediaCommand.Play play && _config.Sound.Captions) + { + Caption(session, play.Request); + } + } + + private MediaQueue? QueueFor(WorldSession session) + { + if (_queues.TryGetValue(session, out var queue)) + { + return queue; + } + + if (!_config.Sound.Enabled || OpenAudio() is not { } output) + { + return null; + } + + queue = new MediaQueue(output, _media, $"{session.World.Host}:{session.World.Port}", _time) + { + Post = OnUiThread, + Mix = _config.Sound.Mix(_muted), + Download = session.World.DownloadMedia, + }; + queue.Started += (_, request) => + { + if (_config.Sound.Captions) + { + Caption(session, request); + } + }; + queue.Failed += (_, failure) => + _messages.Record(_time.GetUtcNow(), MessageSeverity.Info, $"sound {failure.Request.Name} not played — {failure.Problem}", SessionTitle(session)); + _queues[session] = queue; + return queue; + } + + private IAudioOutput? OpenAudio() + { + if (!_audioOpened) + { + _audioOpened = true; + _audio = Host.Audio?.Invoke(); + if (CanPlaySound && _audio is null) + { + Notice("No sound device could be opened; game sound is captions only.", MessageSeverity.Info, key: "sound"); + } + } + + return _audio; + } + + /// A dim ♪ caption row in the session's window. Chrome: it is the client's line, not the game's. + private void Caption(WorldSession session, MediaRequest request) + { + if (_sessionWindows.TryGetValue(session, out var windowId)) + { + AppendWindowLine(windowId, $"[dim]♪ {Escape(Snippet(request.CaptionText))}[/]", StampNow()); + } + } + + /// Re-applies the F7 levels and the mute to every queue; a level edit is heard at once. + private void ApplySoundMix() + { + foreach (var (session, queue) in _queues) + { + queue.Mix = _config.Sound.Mix(_muted); + queue.Download = session.World.DownloadMedia; + if (!_config.Sound.Enabled) + { + queue.Stop(MediaFilter.All); + } + } + } + + private void SetMuted(bool muted) + { + _muted = muted; + ApplySoundMix(); + Notice(muted ? "Sound muted." : "Sound on.", MessageSeverity.Info, key: "sound"); + RefreshStatusBar(); + } + + private void StopSound(WorldSession session) + { + if (_queues.Remove(session, out var queue)) + { + queue.Dispose(); + } + } + + private void DisposeSound() + { + foreach (var queue in _queues.Values) + { + queue.Dispose(); + } + + _queues.Clear(); + (_audio as IDisposable)?.Dispose(); + _audio = null; + } +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index e223cc4c..8c187c52 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -6,6 +6,9 @@ using SharpMUTerm.Core.Diagnostics; using SharpMUTerm.Core.Input; using SharpMUTerm.Core.Logging; +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Packages; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Session; using SharpMUTerm.Core.Telnet; @@ -15,6 +18,7 @@ using SharpMUTerm.Core.Theming; using SharpMUTerm.Core.Workspaces; using SharpMUTerm.Graphics; +using SharpMUTerm.Scripting; using SharpConsoleUI; using SharpConsoleUI.Builders; using SharpConsoleUI.Configuration; @@ -211,7 +215,13 @@ private sealed class SizeReport private readonly InputBarControl _input = new(); private readonly InputBarControl _second = new(); private InputBarControl _armed; - private readonly GmcpStats _stats = new(); + + /// + /// The game data the demo scene shows, standing in for a session's own + /// in the views that draw it. Written through the same the wire uses, + /// and empty in every other frame, so no frame but those carries a meter. + /// + private readonly GameDataStore _demoGameData = new(); private readonly SharpMUTerm.Web.WebPageFetcher _fetcher = new(); private readonly WebImageLoader _imageLoader = new(); @@ -296,6 +306,7 @@ private sealed class SizeReport /// The ⌃P ▸ Show client messages viewer over the diagnostics log. private readonly MessageLogOverlay _messageLog; + private readonly GameDataOverlay _gameData; /// The ⌃Q confirmation. Nothing ends the loop except a yes it collected. private readonly QuitOverlay _quit; @@ -521,6 +532,43 @@ private sealed class SizeReport /// private readonly RestoreLog? _restore; + /// + /// The folder Lua files are read from, or null for an app that runs no scripts — the default, and what + /// every test and snapshot gets. The save/logRoot/restore family once more: a + /// trigger set names its files, and only Program may say where on disk they are read from. + /// + private readonly string? _scriptsRoot; + + /// + /// The folder each character's store.* file is kept in, or null to keep those values in memory only: + /// the same family, so a test or a snapshot that runs scripts still writes no store. + /// + private readonly string? _scriptStoreRoot; + + /// Opens the composer's dictionary; null in a test and a snapshot, which therefore read none. + private readonly Func? _spelling; + + /// Each open session's scripts. Empty when is null. + private readonly Dictionary _scripts = new(); + + /// + /// Where maps are kept: memory-only unless Program hands one a folder, the mssp: rule. Never + /// null, so a recorder needs no "is there a store" branch. + /// + private readonly MapStore _maps; + + /// Each open session's map: its recorder, fed by its GMCP and text room sources, and its walk. + private readonly Dictionary _recorders = new(); + + /// + /// The map windows' controls, by window id. Not in : a map is drawn from the recorder, + /// never from a line buffer, and a control there would be re-fed from one. + /// + private readonly Dictionary _mapControls = new(StringComparer.Ordinal); + + /// The box each map window was last drawn into, so a resize redraws it and a frame does not. + private readonly Dictionary _mapSizes = new(StringComparer.Ordinal); + /// /// Every server's last MSSP report, which is what the F5 ▸ i INFO screen reads. Never null, /// and that is the difference from the three above rather than an inconsistency with them: a cache @@ -624,7 +672,13 @@ public SharpMUTermApp( MsspCache? mssp = null, bool? focusReporting = null, Action? openUrl = null, - Action? clipboard = null) + Action? clipboard = null, + string? scriptsRoot = null, + MapStore? maps = null, + MediaCache? media = null, + PackageStore? packages = null, + string? scriptStoreRoot = null, + Func? spelling = null) : this( config, ClientHost.ForHarness(capabilities, driver, focusReporting, openUrl, clipboard), @@ -633,7 +687,13 @@ public SharpMUTermApp( save, logRoot, restore, - mssp) + mssp, + scriptsRoot, + maps, + media, + packages, + scriptStoreRoot, + spelling) { } @@ -649,8 +709,15 @@ public SharpMUTermApp( Action? save = null, string? logRoot = null, RestoreLog? restore = null, - MsspCache? mssp = null) - { + MsspCache? mssp = null, + string? scriptsRoot = null, + MapStore? maps = null, + MediaCache? media = null, + PackageStore? packages = null, + string? scriptStoreRoot = null, + Func? spelling = null) + { + _spelling = spelling; var capabilities = host.Capabilities; Host = host; _config = config; @@ -660,10 +727,18 @@ public SharpMUTermApp( _pointer = host.Pointer; _logRoot = string.IsNullOrWhiteSpace(logRoot) ? null : logRoot; _restore = restore; + _scriptsRoot = string.IsNullOrWhiteSpace(scriptsRoot) ? null : scriptsRoot; + _scriptStoreRoot = string.IsNullOrWhiteSpace(scriptStoreRoot) ? null : scriptStoreRoot; + _sessions.SessionAdded += OnSessionAdded; + _sessions.SessionRemoved += OnSessionRemoved; _inlineImages = new InlineImageHost( (url, token) => (ImageFetch ?? _imageLoader.FetchBytesAsync)(url, token), OnUi); _mssp = mssp ?? new MsspCache(); + _maps = maps ?? new MapStore(); + _media = media ?? new MediaCache(); + _packages = packages ?? new PackageStore(); + LoadPackages(); _capabilities = capabilities; if (capabilities.SupportsSixelOverlay) { @@ -819,8 +894,11 @@ public SharpMUTermApp( _palette = new CommandPalette(_system, BuildCatalog, () => _active?.SessionKey, id => DispatchCommand(id)); _messageLog = new MessageLogOverlay(_system, _diagnostics); + _gameData = new GameDataOverlay(_system, _time); _settings = new SettingsOverlay(_system); - _composer = new ComposeOverlay(_system); + _composer = new ComposeOverlay(_system, _spelling); + _packageView = new ListOverlay(_system); + _sendMenu = new ListOverlay(_system); _composer.Send += (_, result) => SendComposed(result); _composer.Closed += (_, result) => KeepComposeDraft(result); _quit = new QuitOverlay(_system, QuitFactsNow, Quit); @@ -893,6 +971,7 @@ public SharpMUTermApp( SyncTransparentColours(); DrawSixelOverlay(buffer); PublishScrollbars(); + SyncMapSizes(); }; if (_sixel is not null) { @@ -1057,6 +1136,19 @@ public string RenderSnapshot(string? view = null) canSend: true); } + // The composer holding a MOO verb to edit: the header names it and its type, and the footer offers a + // save and no escaping. The demo has no MCP session, so the edit is handed in as the composer takes it. + if (string.Equals(view, "moo-edit", StringComparison.OrdinalIgnoreCase)) + { + _composer.Toggle( + DemoScene.MainCharacterName, + "\"Usage: look [object]\";\nif (!args)\n player:tell(this.location:description());\n return 1;\nendif\n" + + "thing = player:my_match_object(argstr);\nthing:look_self();", + ComposeEscaping.AsTyped, + canSend: true, + new SharpMUTerm.Core.Telnet.MooEdit("#3:look", "#3:look", "moo-code", [])); + } + // The reported defect, as a frame: a URL too long for the pane it arrived in. It splits first // precisely so the pane is narrower than the terminal — that is the whole bug. The emulator's own // URL detection works on the terminal *row*, so a URL wrapped inside a pane is two fragments to @@ -1112,6 +1204,33 @@ public string RenderSnapshot(string? view = null) AwaitSnapshotPictures(); } + // An MXP menu, through the real parser and pane path, and then the real right-click on it: + // a snapshot host draws no menus, as a terminal does not, so the client's own chooser opens. + if (string.Equals(view, "send-menu", StringComparison.OrdinalIgnoreCase)) + { + var mxp = new MxpParser(); + foreach (var text in new[] + { + "A merchant lays out her wares on a striped cloth.", + "\x1b[1zOn the cloth: a brass lantern.", + }) + { + mxp.Feed(text); + if (mxp.Flush() is { } line) + { + AppendSessionLine(MainWindowId, line, StampNow()); + } + } + + SettleScroll(); + // The chooser itself rather than a right-click, which the window answers with its own native + // menu: this is the frame of the terminal's chooser, drawn the same in both hosts. + if (MenuLinkCell(MainWindowId) is { } cell && TargetAt(cell) is { } target && LinkOf(target) is { } link) + { + ShowSendMenu(link.WindowId, link.Payload); + } + } + // A figure inside a box, as a server that lays pictures out sends it over MXP: the tag sized in // cells, and those cells left blank on its row and the eight under it, with text beside them. The // picture is drawn in the cells, so the box keeps its rows and both its edges; with no graphics the @@ -1577,6 +1696,58 @@ public string RenderSnapshot(string? view = null) _messageLog.Toggle(); } + // The map window, over the demo's recorded streets: opened through the real ⌃P entry; `map-walking` + // starts a walk to the docks through the real `/walk`, so the path ahead is marked; `map-split` puts + // the map beside the output, the way it is meant to be read. + if (view?.ToLowerInvariant() is "map" or "map-walking" or "map-split") + { + LoadDemoMap(); + var split = string.Equals(view, "map-split", StringComparison.OrdinalIgnoreCase); + if (split) + { + // Split first, so the map opens in front in the right-hand pane, then hand the focus back to + // the output on the left: the map is read beside what you type into, not instead of it. + PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); + RebuildPaneArea(); + if (_workspace.Layout.Panes[^1].ActiveTab is { } right) + { + Activate(right); + } + } + + DispatchCommand("term:map"); + if (string.Equals(view, "map-walking", StringComparison.OrdinalIgnoreCase)) + { + MapCommand(MapWindowId(DemoScene.ActiveSessionKey), "walk", "Docks Gate"); + } + + if (split) + { + Activate(MainWindowId); + RefreshRail(); + } + } + + // The Packages screen with two installed (one switched off), the review an install opens with, and + // the index with this world's packages first — all through the real paths, into the snapshot's memory-only store. + if (view?.ToLowerInvariant() is "packages" or "package-review" or "package-browse") + { + LoadDemoPackages(view.ToLowerInvariant()); + } + + // The status row's meters and the game data inspector, over the demo's game data — written through + // GameDataStore.SetGmcp, the call the wire makes, so the frame shows what a server's messages produce. + // `meters` is the row alone; `gamedata` opens the inspector through the real ⌃P entry. + if (string.Equals(view, "meters", StringComparison.OrdinalIgnoreCase) + || string.Equals(view, "gamedata", StringComparison.OrdinalIgnoreCase)) + { + LoadDemoGameData(); + if (string.Equals(view, "gamedata", StringComparison.OrdinalIgnoreCase)) + { + DispatchCommand("term:gamedata"); + } + } + // The ⌃Q confirmation, over a workspace that has something to lose: a second world marked // connected (the demo scene's own way of saying so, and what the header's "n/m characters" reads), // a line typed into the command line through the bar's real change notification, and then the @@ -2375,7 +2546,7 @@ private WorldSession OpenSession(WorldDefinition world, CharacterDefinition? cha : _sessions.Open( world, character, - _config.ResolveTriggerSets(character), + _config.ResolveTriggerSets(character, world), _config.ScrollbackLines, OpenLog(world, character), _config.Text, @@ -2568,14 +2739,32 @@ private void BindSession(WorldSession session, string? windowId = null) // cache so the snapshot's demo report is written by the same code the wire is (see DemoScene's // remarks on state a live session writes). session.MsspReceived += (_, e) => OnUi(() => CaptureMssp(session.World, e.Data)); - session.GmcpReceived += (_, e) => OnUi(() => + // The meters read the session's game data, which the session has already stored by the time this is + // raised. Only the session the status row describes repaints it. + session.GameData.Changed += (_, _) => OnUi(() => { - if (_stats.Update(e.Package, e.Json)) + if (ReferenceEquals(session, _active)) { - UpdateStatus(); + RefreshStatusBar(); } }); session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Line)); + session.MooEditReceived += (_, edit) => OnUi(() => ReceiveMooEdit(session, edit)); + session.EditGrabbed += (_, text) => OnUi(() => ReceiveGrab(session, windowId, text)); + session.McpStatusChanged += (_, _) => OnUi(() => + { + if (ReferenceEquals(session, _active)) + { + RefreshStatusBar(); + } + }); + session.MxpMetersChanged += (_, _) => OnUi(() => + { + if (ReferenceEquals(session, _active)) + { + RefreshStatusBar(); + } + }); // The status row's encoding cell is live, so it has to be repainted when the thing it reports // changes. WorldSession has already put the change in the client message log by the time this @@ -2614,7 +2803,7 @@ private void ReportAutomation(WorldSession session) } var who = SessionTitle(session); - var sets = _config.ResolveTriggerSets(character); + var sets = _config.ResolveTriggerSets(character, session.World); _diagnostics.Logger.LogInformation( "{Notice:l}", $"{who}: {TriggerReport.Summary(who, character.TriggerSets, sets)} (/triggers for detail)"); @@ -4336,6 +4525,7 @@ private void OnLine(WorldSession session, string windowId, StyledLine line) var stamp = StampNow(); AppendSessionLine(windowId, line, stamp); RecordForRestore(session, windowId, WindowTitle(windowId), line, stamp); + AnnounceLine(windowId, line.Text); if (!_workspace.IsCaughtUp(windowId)) { @@ -4395,6 +4585,7 @@ private void OnSpawnLine(WorldSession session, string target, StyledLine line) var stamp = StampNow(); AppendSessionLine(window.Id, line, stamp); RecordForRestore(session, window.Id, window.Title, line, stamp); + AnnounceLine(window.Id, line.Text); // A first-seen destination adds a tab to its pane, so rebuild; otherwise just refresh badges. if (existed) @@ -4425,6 +4616,28 @@ private void OnCommandEntered(InputBar bar, string command) return; } + // `/package` opens the Packages screen; `/package install …` reads one for review. + if (command.StartsWith("/package", StringComparison.OrdinalIgnoreCase) + && (command.Length == 8 || command[8] == ' ')) + { + PackageCommand(command[8..].Trim()); + return; + } + + // `/walk` and `/map` speak to the window's own connection's map. + if (command.StartsWith("/walk", StringComparison.OrdinalIgnoreCase) + && (command.Length == 5 || command[5] == ' ')) + { + MapCommand(windowId, "walk", command[5..].Trim()); + return; + } + + if (command.StartsWith("/map ", StringComparison.OrdinalIgnoreCase)) + { + MapCommand(windowId, "map", command[5..].Trim()); + return; + } + // `/graphics` reports where the degradation chain settled and, when it degraded, why — so a // missing picture is an explanation rather than a mystery — and then what the page in the web // view actually did with its images, which is the difference between "nothing arrived" and @@ -4523,15 +4736,19 @@ private void ToggleComposer() var windowId = ActiveWindowId(); var session = WindowSession(windowId); - var draft = ComposeDraftKey(windowId) is { } key && _composeDrafts.TryGetValue(key, out var kept) - ? kept - : new ComposeResult(string.Empty, ComposeEscaping.AsTyped); + var key = ComposeDraftKey(windowId); + var draft = key is not null && _mooEdits.TryGetValue(key, out var edit) + ? edit + : key is not null && _composeDrafts.TryGetValue(key, out var kept) + ? kept + : new ComposeResult(string.Empty, ComposeEscaping.AsTyped); _composer.Toggle( session is null ? string.Empty : SessionTitle(session), draft.Body, draft.Escaping, - canSend: session is not null); + canSend: session is not null, + draft.Edit); } /// @@ -4574,6 +4791,12 @@ private void SendComposed(ComposeResult result) return; } + if (result.Edit is { } edit) + { + SaveMooEdit(session, edit, result.Body); + return; + } + if (ComposeMessage.Build(result.Body, result.Escaping) is not { } line) { // An empty buffer is not a blank command to send: a MUSH answers a blank line, and some log @@ -4603,6 +4826,13 @@ private void KeepComposeDraft(ComposeResult result) return; } + // An edit is kept even empty: emptying a property is an edit, and the post draft is not touched. + if (result.Edit is not null) + { + _mooEdits[key] = result; + return; + } + if (result.Body.Trim().Length == 0) { _composeDrafts.Remove(key); @@ -4651,7 +4881,7 @@ internal IReadOnlyList TriggerReportNow() return TriggerReport.Describe( SessionTitle(session), character?.TriggerSets ?? (IReadOnlyList)Array.Empty(), - character is null ? Array.Empty() : _config.ResolveTriggerSets(character), + character is null ? Array.Empty() : _config.ResolveTriggerSets(character, session.World), session.Triggers); } @@ -4690,9 +4920,11 @@ private InputBar BarKind(InputBarControl bar) => /// The recall list for a bar under the focused window's character; see . /// Created on first use, so a character nobody has typed to holds nothing. /// - private InputHistory HistoryFor(InputBar bar) + private InputHistory HistoryFor(InputBar bar) => HistoryFor(HistoryOwner(), bar); + + private InputHistory HistoryFor(string owner, InputBar bar) { - var key = (HistoryOwner(), bar); + var key = (owner, bar); if (!_histories.TryGetValue(key, out var history)) { // IgnoreForHistory is asked per line, so a list created late follows the setting as it is now. @@ -4810,7 +5042,7 @@ private void ToggleHistorySearch() /// private bool AnyOverlayOpen => _palette.IsOpen || _settings.IsOpen || _quit.IsOpen || _messageLog.IsOpen || _historySearch.IsOpen - || _prefixPanel.IsOpen || _composer.IsOpen || _search.IsOpen; + || _prefixPanel.IsOpen || _composer.IsOpen || _search.IsOpen || _gameData.IsOpen || _packageView.IsOpen || _sendMenu.IsOpen; /// /// Opens the ⌃F search surface, or closes it when the chord arrives again. @@ -4923,6 +5155,9 @@ private string OpenOverlayName() => : _palette.IsOpen ? "the command surface" : _quit.IsOpen ? "the quit prompt" : _messageLog.IsOpen ? "the client messages" + : _gameData.IsOpen ? "the game data" + : _packageView.IsOpen ? "the packages" + : _sendMenu.IsOpen ? "the menu" : _historySearch.IsOpen ? "the history search" : _search.IsOpen ? "the search surface" : _prefixPanel.IsOpen ? "the pane keys panel" @@ -4952,6 +5187,7 @@ private void SetUpBar(InputBarControl bar, InputBar kind) // and the first chrome refresh. PaintInputBands(PaneTint.None); bar.HasSibling = () => _second.Visible; + bar.Complete = CompleteWord; bar.Entered += text => OnCommandEntered(kind, text); bar.Changed += text => OnInputChanged(kind, text); bar.CutTo = CutToClipboard; @@ -5110,6 +5346,7 @@ private void ChangeWindow() { SyncInputBars(); RecallDrafts(ActiveWindowId()); + UpdatePresence(); } /// @@ -5128,6 +5365,10 @@ private void RecallDrafts(string windowId) _input.Text = _drafts.Recall(windowId, InputBar.Primary); _second.Text = _drafts.Recall(windowId, InputBar.Secondary); + if (_pendingGrabs.Remove(windowId, out var grab)) + { + PlaceGrab(_input, grab); + } } /// Which of the two ways into history a keystroke is, or neither. @@ -6317,6 +6558,11 @@ private void RegisterFocusReportTab() return () => { ToggleComposer(); return true; }; } + if (claim.Key == ConsoleKey.F10) + { + return () => { ShowPackages(); return true; }; + } + if (!screens.TryGetValue(claim.Key, out var open)) { return null; @@ -6537,6 +6783,7 @@ private void ReloadAutomation() { foreach (var session in _sessions.Sessions) { + session.MsdpReports = StatusMeter.MsdpVariables(_config.Text.StatusMeters); if (session.Character is not { } character) { continue; // an anonymous connection has no character, so nothing resolves for it @@ -6546,9 +6793,16 @@ private void ReloadAutomation() + session.Aliases.Aliases.Count + session.Macros.Macros.Count; - var sets = _config.ResolveTriggerSets(character); + var sets = _config.ResolveTriggerSets(character, session.World); session.ReloadAutomation(sets); + // A set gaining or losing a script file reloads that session's Lua; any other edit leaves the + // scripts alone, because a rebuild restarts their timers and this runs on every committed change. + if (_scripts.TryGetValue(session, out var scripts) && scripts.SyncFiles()) + { + Notice($"{SessionTitle(session)}: {ScriptsLoaded(scripts)}", ScriptsLoadedSeverity(scripts)); + } + var after = session.Triggers.Triggers.Count + session.Aliases.Aliases.Count + session.Macros.Macros.Count; @@ -6600,6 +6854,9 @@ internal void SaveConfiguration() ApplyDesktopTransparency(); _window.BackgroundColor = BackdropColor(); + // And F7's sound levels, and F5's per-world download switch, which a playing queue holds a copy of. + ApplySoundMix(); + PersistConfiguration(); } @@ -6919,7 +7176,7 @@ private ScreenBinding TimersScreen() /// Opens the F7 Text & ANSI screen, bound to the app's text preferences. private ScreenBinding TextAnsiScreen() => - OptionsScreen(() => OptionsScreenRenderer.TextAnsiScreen(_config.Text)); + OptionsScreen(() => OptionsScreenRenderer.TextAnsiScreen(_config.Text, _config.Sound)); /// Opens the F8 Input screen, bound to the app's input preferences. private ScreenBinding InputScreen() => @@ -7234,7 +7491,8 @@ private IReadOnlyList BuildRail() Active: active, Unread: windows.Sum(w => w.Unread), windows, - Chord: characterChords.GetValueOrDefault(key))); + Chord: characterChords.GetValueOrDefault(key), + InGroup: _sendGroup.Contains(key))); } worlds.Add(new RailWorld(world.Name, world.Host, world.Port, accent, characters)); @@ -7435,7 +7693,11 @@ internal IReadOnlyList BuildCatalog() TimestampsOn: ShowTimestamps, SecondInputOn: _secondBars.IsShown(ActiveWindowId()), ScrolledBack: ScrollTarget() is { CanScrollDown: true }, - NewlineChord: Host.Keys.Newline.Glyph); + NewlineChord: Host.Keys.Newline.Glyph, + ScriptsOn: _scriptsRoot is not null, + Walking: SendTarget() is { } walker && _recorders.TryGetValue(walker, out var map) && map.Walk.IsWalking, + Sound: CanPlaySound ? !_muted : null, + Announced: Host.Announcer is null ? null : IsAnnounced(ActiveWindowId())); return CommandCatalog.Build( _workspace, BuildCharacterRefs(), _active?.SessionKey, context, SettingsCommands()); } @@ -7472,7 +7734,8 @@ private IReadOnlyList BuildCharacterRefs() character.Name, key, Connected: connected.Contains(key), - Open: _sessions.Find(key) is not null)); + Open: _sessions.Find(key) is not null, + InGroup: _sendGroup.Contains(key))); } } @@ -7697,6 +7960,17 @@ internal IReadOnlyList PaneLines(string windowId) => /// internal bool DispatchCommand(string id) { + if (DispatchGroupCommand(id)) + { + return true; + } + + if (id == "a11y:toggle-window") + { + ToggleAnnounced(); + return true; + } + if (id.StartsWith(CharacterCommandPrefix, StringComparison.Ordinal)) { SwitchToCharacter(id[CharacterCommandPrefix.Length..]); @@ -7822,6 +8096,27 @@ internal bool DispatchCommand(string id) _messageLog.Toggle(); } + return true; + case "term:map": + ShowMap(); + return true; + case "term:mute": + case "term:unmute": + SetMuted(id == "term:mute"); + return true; + case "term:walk-stop": + if (SendTarget() is { } walker && _recorders.TryGetValue(walker, out var walking)) + { + walking.Walk.Stop("stopped"); + } + + return true; + case "term:gamedata": + if (!ComposerIsInTheWay("the game data")) + { + ShowGameData(); + } + return true; case "term:restore-purge": PurgeRestoreLog(); @@ -7832,6 +8127,15 @@ internal bool DispatchCommand(string id) case "term:compose": ToggleComposer(); return true; + case "term:packages": + ShowPackages(); + return true; + case "term:package-browse": + BrowsePackages(); + return true; + case "term:package-install": + ActiveBar().SetAndNotify("/package install "); + return true; case "term:log-on": StartLogging(); break; @@ -7863,6 +8167,9 @@ internal bool DispatchCommand(string id) case "world:disconnect": Disconnect(); break; + case "world:reload-scripts": + ReloadScripts(); + break; default: // On the status line, never through _active: an unwired command is exactly what you hit // while nothing is connected, and a message that needs a session to be seen cannot @@ -7918,6 +8225,495 @@ internal bool DispatchCommand(string id) /// of it: the client asks before ending itself (⌃Q) and before nothing else, and a prompt in front of /// the key you press because a connection has gone wrong is friction at the worst moment. /// + /// + /// Starts a session's scripts as it opens. Raised for every session the manager holds, so a session + /// opened by any path gets them; Lua printing at load lands in the session's scrollback, which + /// replays into its window. + /// + private void OnSessionAdded(object? sender, WorldSession session) + { + session.MsdpReports = StatusMeter.MsdpVariables(_config.Text.StatusMeters); + WatchPresence(session); + StartMapping(session); + StartSound(session); + if (_scriptsRoot is null || _scripts.ContainsKey(session)) + { + return; + } + + var scripts = new SessionScripts(session, _scriptsRoot, text => ReportScript(session, text), _packages.ResolveScript, _scriptStoreRoot, ScriptSendTo); + _scripts[session] = scripts; + scripts.Reload(); + } + + private void OnSessionRemoved(object? sender, WorldSession session) + { + StopSound(session); + _sendGroup.Remove(session.SessionKey); + if (_scripts.Remove(session, out var scripts)) + { + scripts.Dispose(); + } + + if (_recorders.Remove(session, out var map)) + { + SaveMap(session, map); + session.Map = null; + map.Dispose(); + } + } + + /// + /// Starts recording the session's map. Observations arrive on the read loop and are applied on the UI + /// thread, in the same queue as the commands that say which way a room was left, so the two keep their + /// order. The map is written when the connection closes and when the client exits, not per room. + /// + private void StartMapping(WorldSession session) + { + if (_recorders.ContainsKey(session)) + { + return; + } + + var text = new TextRoomSource(session, session.World.Map); + if (text.Problem is { } problem) + { + session.PrintSystem($"*** {problem}"); + } + + IRoomSource[] sources = text.IsActive + ? [new GmcpRoomSource(session), new MxpRoomSource(session), text] + : [new GmcpRoomSource(session), new MxpRoomSource(session)]; + if (!text.IsActive) + { + text.Dispose(); + } + + var recorder = new MapRecorder(_maps.For(session.World.Host, session.World.Port), sources) { Post = OnUiThread }; + var walk = new Speedwalk( + recorder, command => session.SendCommandAsync(command), session.World.Map.WalkFailurePattern, _time) + { + Post = OnUiThread, + }; + var map = new SessionMap(recorder, walk) { Post = OnUiThread }; + _recorders[session] = map; + session.Map = map; + session.CommandSent += (_, command) => OnUiThread(() => recorder.NoteCommand(command)); + session.LinePrinted += (_, line) => + { + var text = line.Text; + OnUiThread(() => walk.Observe(text)); + }; + recorder.Changed += (_, _) => + { + RenderMap(MapWindowId(session.SessionKey)); + RefreshStatusBar(); + }; + walk.Changed += (_, reason) => OnWalkChanged(session, reason); + session.StateChanged += (_, e) => + { + if (e.State is ConnectionState.Disconnected or ConnectionState.Faulted) + { + OnUiThread(() => + { + walk.Stop("stopped: disconnected"); + SaveMap(session, map); + }); + } + }; + } + + private void SaveMap(WorldSession session, SessionMap map) + { + if (!map.Recorder.IsDirty) + { + return; + } + + _maps.Save(session.World.Host, session.World.Port); + map.Recorder.MarkSaved(); + } + + /// The focused connection's map recorder, or null. + internal MapRecorder? MapFor(WorldSession session) => _recorders.GetValueOrDefault(session)?.Recorder; + + /// A session's map and walk, or null. + internal SessionMap? SessionMapFor(WorldSession session) => _recorders.GetValueOrDefault(session); + + /// + /// The demo scene's map: recorded through the real by , + /// and filed under the demo's character, which has no session to hold it. + /// + private SessionMap? _demoMap; + + /// The map a session key's connection keeps, or the demo's for the demo character. + private SessionMap? MapOf(string? sessionKey) + { + if (sessionKey is null) + { + return null; + } + + if (FindSession(sessionKey) is { } session && _recorders.TryGetValue(session, out var map)) + { + return map; + } + + return _sessions.Sessions.Count == 0 && sessionKey == DemoScene.ActiveSessionKey ? _demoMap : null; + } + + private const string MapWindowPrefix = "map:"; + + /// The id of a session's map window. One per connection, owned by it. + internal static string MapWindowId(string sessionKey) => MapWindowPrefix + sessionKey; + + private static bool IsMapWindow(string windowId) => windowId.StartsWith(MapWindowPrefix, StringComparison.Ordinal); + + /// + /// ⌃P ▸ Show map: opens the focused connection's map as a tab of the focused pane, or goes to it. A + /// window with no connection refuses, for 's reason. + /// + private void ShowMap() + { + var key = SendTarget()?.SessionKey ?? _workspace.FindWindow(ActiveWindowId())?.SessionKey; + if (MapOf(key) is null) + { + RefuseCommand("no map here — pick a character first (⌃P ▸ Switch to …)"); + return; + } + + var windowId = MapWindowId(key!); + if (_workspace.FindWindow(windowId) is null) + { + _workspace.OpenWindow(windowId, "Map", WindowKind.Auxiliary, key); + RebuildPaneArea(); + } + + Activate(windowId); + RenderMap(windowId); + } + + /// A map window's control, made on first use with the map's own link handler. + private MarkupControl MapControlFor(string windowId) + { + if (_mapControls.TryGetValue(windowId, out var existing)) + { + return existing; + } + + var control = new MarkupControl(new List()) + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Fill, + }; + control.LinkClicked += (_, e) => OnMapLinkClicked(windowId, e.Url); + _mapControls[windowId] = control; + return control; + } + + /// + /// A click on a room walks there. The handler belongs to the map window alone and answers only + /// : a map holds no server text that could carry another scheme, and the + /// output panes' handler, which does, never sees a map link. The session is the window's owner, never + /// _active. + /// + private void OnMapLinkClicked(string windowId, string url) + { + if (MapRenderer.ParseLink(url) is not { } room + || MapOf(_workspace.FindWindow(windowId)?.SessionKey) is not { } map) + { + return; + } + + if (map.WalkTo(room) is { } problem) + { + Notice($"Cannot walk there: {problem}.", key: "map"); + } + } + + /// + /// Draws a map window from its session's recorder, into the box its pane gives it. A window no pane shows + /// is left alone until one does (). + /// + private void RenderMap(string windowId) + { + if (!_mapControls.TryGetValue(windowId, out var control)) + { + return; + } + + var (width, height) = MapBox(windowId); + _mapSizes[windowId] = (width, height); + if (MapOf(_workspace.FindWindow(windowId)?.SessionKey) is not { } map) + { + control.SetContent(new List { "[dim]No map: this character is not connected.[/]" }); + return; + } + + var walking = map.Walk.IsWalking ? $"walking, {map.Walk.Remaining} to go" : null; + var ink = new MapRenderer.Ink(_ink.Accent, _ink.Accent, _ink.Notice, _ink.Draft); + var lines = MapRenderer.Render( + map.Map, map.Recorder.CurrentRoomId, map.Walk.RoomsAhead, width, height, ink, walking); + _mapMarkup[windowId] = lines; + control.SetContent(lines.ToList()); + } + + /// What a map window was last drawn as, for a test to read. + private readonly Dictionary> _mapMarkup = new(StringComparer.Ordinal); + + internal IReadOnlyList MapMarkup(string windowId) => + _mapMarkup.TryGetValue(windowId, out var lines) ? lines : Array.Empty(); + + /// A click on a map window's link, as its own handler receives it. + internal void SimulateMapClick(string windowId, string url) => OnMapLinkClicked(windowId, url); + + /// The cells a map window has: its pane's output rectangle, or a fallback before one is arranged. + private (int Width, int Height) MapBox(string windowId) + { + if (_workspace.Layout.FindWindow(windowId) is { } pane + && PaneOutputRects().TryGetValue(pane.Id, out var rect) + && !rect.IsEmpty) + { + return (rect.Width, rect.Height); + } + + return (Math.Max(20, _system.ConsoleDriver.ScreenSize.Width - 24), Math.Max(4, _system.ConsoleDriver.ScreenSize.Height - 6)); + } + + /// Redraws a map whose box changed. Rides PostBufferPaint, where pane rectangles exist. + private void SyncMapSizes() + { + foreach (var windowId in _mapControls.Keys) + { + if (_mapSizes.TryGetValue(windowId, out var was) && was == MapBox(windowId)) + { + continue; + } + + RenderMap(windowId); + } + } + + /// A walk took a step or stopped: redraw its map, and say why it stopped. + private void OnWalkChanged(WorldSession session, string? reason) + { + RenderMap(MapWindowId(session.SessionKey)); + if (reason is not null) + { + Notice($"Walk {reason}.", MessageSeverity.Info, key: "map"); + } + } + + /// + /// /walk <room> (a number or a name), /walk stop; /map lock|unlock [room], the current + /// room when none is named. Answered in the window, like /graphics. + /// + private void MapCommand(string windowId, string verb, string argument) + { + if (MapOf(WindowSession(windowId)?.SessionKey ?? _workspace.FindWindow(windowId)?.SessionKey) is not { } map) + { + AppendWindowLine(windowId, "[dim]*** No map: this window has no connection.[/]"); + return; + } + + string? problem; + if (verb == "walk") + { + if (argument.Equals("stop", StringComparison.OrdinalIgnoreCase) || argument.Length == 0) + { + problem = map.Walk.IsWalking ? null : "not walking"; + map.Walk.Stop("stopped"); + } + else + { + problem = map.WalkTo(argument); + } + } + else + { + var parts = argument.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var locking = parts.ElementAtOrDefault(0)?.ToLowerInvariant(); + var target = parts.ElementAtOrDefault(1); + var room = target is null ? map.Recorder.CurrentRoom : map.Find(target); + problem = locking is not ("lock" or "unlock") ? "use /map lock [room] or /map unlock [room]" + : room is null ? "no such room" + : map.SetLocked(room.Id, locking == "lock") ? null + : "no such room"; + if (problem is null) + { + AppendWindowLine(windowId, $"[dim]*** {Escape(room!.Name)} is {(locking == "lock" ? "locked: no walk goes through it" : "unlocked")}.[/]"); + } + } + + if (problem is not null) + { + AppendWindowLine(windowId, $"[dim]*** {Escape(problem)}.[/]"); + } + } + + /// + /// A script failure is said twice: in the session's own window, where the reader is looking when a + /// trigger misfires, and in the client messages, which outlive the scrollback. It can arrive on the + /// telnet read loop, so the notice is marshalled; the window line is not, because a session's lines + /// already arrive from that thread. + /// + private void ReportScript(WorldSession session, string text) + { + session.PrintSystem($"*** {text}"); + OnUi(() => Notice($"{SessionTitle(session)}: {text}")); + } + + /// + /// What the last load did, counted from the files that loaded rather than the files named, so a missing + /// or refused file is not followed by a notice saying it loaded. + /// + private static string ScriptsLoaded(SessionScripts scripts) => (scripts.Files.Count, scripts.LoadedCount) switch + { + (0, _) => "no scripts", + (var named, var loaded) when loaded < named => $"{loaded} of {named} scripts loaded", + (1, _) => "1 script loaded", + (var named, _) => $"{named} scripts loaded", + }; + + private static MessageSeverity ScriptsLoadedSeverity(SessionScripts scripts) => + scripts.LoadedCount < scripts.Files.Count ? MessageSeverity.Warning : MessageSeverity.Info; + + /// + /// ⌃P ▸ Reload scripts: re-reads the focused character's script files from disk. Resolved through + /// , the window in front of you, for 's reason. + /// + private void ReloadScripts() + { + if (_scriptsRoot is null) + { + RefuseCommand("this client runs no scripts"); + return; + } + + if (SendTarget() is not { } session || !_scripts.TryGetValue(session, out var scripts)) + { + RefuseCommand("no scripts to reload — pick a character first (⌃P ▸ Switch to …)"); + return; + } + + scripts.Reload(); + Notice($"{SessionTitle(session)}: {ScriptsLoaded(scripts)}", ScriptsLoadedSeverity(scripts)); + } + + /// Whether the focused character's scripts define a function by this name. Null with no scripts. + internal bool? ScriptDefines(string name) => + SendTarget() is { } session && _scripts.TryGetValue(session, out var scripts) + ? scripts.Defines(name) + : null; + + /// + /// ⌃P ▸ Show game data: the inspector, over the focused window's connection — , + /// for 's reason. A window with no connection refuses out loud. + /// + private void ShowGameData() + { + if (_gameData.IsOpen) + { + _gameData.Toggle(_demoGameData, string.Empty); + return; + } + + if (SendTarget() is { } session) + { + _gameData.Toggle(session.GameData, SessionTitle(session)); + return; + } + + if (_sessions.Sessions.Count == 0 && _demoGameData.Count > 0) + { + _gameData.Toggle(_demoGameData, "Corvid"); + return; + } + + RefuseCommand("no game data here — pick a character first (⌃P ▸ Switch to …)"); + } + + internal bool GameDataIsOpen => _gameData.IsOpen; + + /// What an IRE-style server sends after the handshake, for the meters and gamedata views. + private void LoadDemoGameData() + { + _demoGameData.SetGmcp("Char.Vitals", """{"hp":"412","maxhp":"500","mp":"23","maxmp":"120","moves":"88"}"""); + _demoGameData.SetGmcp("Char.MaxStats", """{"maxmoves":"100"}"""); + _demoGameData.SetGmcp("Char.Status", """{"name":"Corvid","level":"42","class":"Sentinel","gold":"1275"}"""); + _demoGameData.SetGmcp( + "Room.Info", + """{"num":4201,"name":"The Grand Plaza","area":"Aetherfall","environment":"urban","exits":{"n":4202,"e":4210,"s":4199}}"""); + _demoGameData.SetGmcp("Comm.Channel.List", """[{"name":"ooc","caption":"Out of character"},{"name":"trade","caption":"Trade"}]"""); + RefreshStatusBar(); + } + + /// + /// A few streets of Aetherfall, walked through and + /// the way a GMCP game is, so the map views show what recording + /// produces rather than a map posed by hand. The walk ends back on the plaza. + /// + private void LoadDemoMap() + { + var recorder = new MapRecorder(new WorldMap()); + var walk = new Speedwalk(recorder, _ => Task.CompletedTask, time: _time); + _demoMap = new SessionMap(recorder, walk); + recorder.Changed += (_, _) => + { + RenderMap(MapWindowId(DemoScene.ActiveSessionKey)); + RefreshStatusBar(); + }; + walk.Changed += (_, _) => RenderMap(MapWindowId(DemoScene.ActiveSessionKey)); + + var rooms = new Dictionary + { + [4201] = ("The Grand Plaza", [("north", 4202), ("east", 4210), ("south", 4199), ("west", 4220)]), + [4202] = ("Temple Steps", [("south", 4201), ("north", 4203), ("up", 4250)]), + [4203] = ("Temple of the Tide", [("south", 4202)]), + [4210] = ("Market Row", [("west", 4201), ("east", 4211), ("southeast", 4212)]), + [4211] = ("Spice Stall", [("west", 4210)]), + [4212] = ("Docks Gate", [("northwest", 4210), ("south", null)]), + [4199] = ("Lantern Street", [("north", 4201), ("south", null), ("southwest", 4198)]), + [4198] = ("Old Well", [("northeast", 4199)]), + [4220] = ("Guildhall", [("east", 4201), ("west", null)]), + }; + void Arrive(long id) => recorder.Apply(new RoomObservation( + id, + rooms[id].Name, + rooms[id].Exits.Select(e => new ObservedExit(e.Item1, e.Item2)).ToList()) + { + Area = "Aetherfall", + }); + + Arrive(4201); + foreach (var (command, to) in new (string, long)[] + { + ("n", 4202), ("n", 4203), ("s", 4202), ("s", 4201), ("e", 4210), ("e", 4211), ("w", 4210), + ("se", 4212), ("nw", 4210), ("w", 4201), ("s", 4199), ("sw", 4198), ("ne", 4199), ("n", 4201), + ("w", 4220), ("e", 4201), + }) + { + recorder.NoteCommand(command); + Arrive(to); + } + } + + /// The game data the status row's meters read: the active session's, or the demo's. + private GameDataStore StatusData() => _active?.GameData ?? _demoGameData; + + /// The player's own meters, then any a server asked for with MXP <GAUGE> that are not already there. + private IReadOnlyList StatusMeters() + { + var own = _config.Text.StatusMeters; + var served = _active?.MxpMeters ?? _demoMeters; + return served.Count == 0 + ? own + : [.. own, .. served.Where(m => !own.Any(o => string.Equals(o.Value, m.Value, StringComparison.OrdinalIgnoreCase)))]; + } + + /// The demo scene's server meters: written into nothing live, so the snapshot sets them here. + private IReadOnlyList _demoMeters = []; + private void Reconnect() { // Which connection this acts on is decided by the window in front of you — SendTarget, the same @@ -9602,6 +10398,13 @@ private IWindowControl BuildTabContent(PaneNode pane, string windowId, Workspace return BuildWebContent(window.Title); } + if (IsMapWindow(windowId)) + { + var map = MapControlFor(windowId); + RenderMap(windowId); + return map; + } + return OutputViewFor(windowId, window.Title); } @@ -10610,7 +11413,9 @@ private void SplitFocusedPane(PaneCommand command) return null; } - if (session.Macros.Resolve(descriptor) is not { Command.Length: > 0 } macro) + // A macro may send a command, call a script, or both; one that does neither leaves the key alone. + if (session.Macros.Resolve(descriptor) is not { } macro + || (macro.Command.Length == 0 && string.IsNullOrEmpty(macro.ScriptCallback))) { return null; } @@ -11251,6 +12056,7 @@ private void CloseWindow(string id) _paneScrolls.Remove(FrozenRegionKey(id)); _frozenPanes.Remove(id); _drafts.Forget(id); // both bars: a closed window keeps neither of its two drafts + _pendingGrabs.Remove(id); _secondBars.Forget(id); // and a same-id window later starts from F8's default again _lines.Remove(id); // don't resurrect old scrollback if a same-id spawn reopens _reservedPictures.Remove(id); @@ -11310,6 +12116,7 @@ private bool Activate(string id) AdoptSessionOf(id); SelectTabFor(id); SyncToFocusedPane(); + AnnounceFocus(id); } finally { @@ -11922,6 +12729,7 @@ private void Notice(string text, MessageSeverity severity = MessageSeverity.Warn "{Notice:l}", // :l renders the string literally; the default quotes it, and a quoted sentence reads as data key is null ? text : $"{key} {text}"); + AnnounceNotice(text); var body = severity == MessageSeverity.Error ? $"[{ScreenPalette.Warn}]{Escape(text)}[/]" : $"[dim]{Escape(text)}[/]"; @@ -12209,10 +13017,35 @@ private string[] FocusHints() /// private SessionEncoding? EffectiveEncoding() => _active?.CurrentEncoding ?? _demoEncoding; + /// The cells the status row's room field always takes, whatever the room is called. + internal const int RoomCellWidth = 28; + + /// The room the active connection's map says the character is in, or null. + private (long Id, string Name, string? Area)? StatusRoom() => + MapOf(_active?.SessionKey ?? (_sessions.Sessions.Count == 0 ? DemoScene.ActiveSessionKey : null))?.Current; + + /// ⌂ Room · Area, elided and padded to . The room leads: it is what + /// changes, and it is what an elision should keep. + internal static string RoomCell(string name, string? area) + { + var text = string.IsNullOrWhiteSpace(area) ? name : $"{name} · {area}"; + var room = RoomCellWidth - 2; + if (text.Length > room) + { + text = text[..(room - 1)] + "…"; + } + + return $"[dim]⌂ {Escape(text.PadRight(room))}[/]"; + } + + /// The cells the meters leave free for the typed-character count to grow into. + private const int MeterSlack = 4; + private string StatusBarMarkup(string character, string state) { var accent = ActiveWorld() is { } world ? AccentHex(world.Accent) : _ink.Accent; var left = $"[{accent}]●[/] [bold]{Escape(character)}[/] [dim]{Escape(state)}[/]"; + var meters = StatusMeterRenderer.Render(StatusMeters(), StatusData(), $"{_ink.Notice}"); var right = new List(); @@ -12223,6 +13056,12 @@ private string StatusBarMarkup(string character, string state) right.Add(scrollback); } + // Only while muted, and only by a keystroke: nothing from the wire can make this cell appear. + if (_muted) + { + right.Add($"[{_ink.Notice}]{MutedCell}[/]"); + } + // No latency meter. There was one — a heartbeat glyph, a sparkline and a round-trip figure — and // every part of it was invented: the sparkline came from a literal `{ 38, 44, 41, 47, 40, 43 }` // and the figure was the literal 41 whenever keepalive was switched on. It read as telemetry, so @@ -12266,6 +13105,35 @@ private string StatusBarMarkup(string character, string state) // worse trade than a hint that is only there when there is room for it, and this is the same // measurement the header already makes to right-align its own cluster. var rightBar = string.Join(" ", right); + + // The meters sit beside the identity they describe, and only when the row has room for them with a + // few cells to spare: the character count on the right grows as a line is typed, and a cluster that + // came and went with it would be worse than one that is absent on a narrow terminal. + if (meters.Length > 0 + && MarkupWidth(left) + 3 + MarkupWidth(meters) + MarkupWidth(rightBar) + MinStatusGap + MeterSlack <= HeaderWidth()) + { + left = $"{left} {meters}"; + } + + // Where the character stands, once the map knows. A reserved width, so a walk does not move the right + // cluster with every room name; a name too long for it is elided, never wrapped. + if (StatusRoom() is { } here) + { + var cell = RoomCell(here.Name, here.Area); + if (MarkupWidth(left) + 3 + RoomCellWidth + MarkupWidth(rightBar) + MinStatusGap + MeterSlack <= HeaderWidth()) + { + left = $"{left} {cell}"; + } + } + + // What the MOO last put in its status line, once it has put anything there this connection. Reserved + // and elided like the room, for the same reason, and dropped first when the row is short. + if (_active?.McpStatus is { } mooStatus + && MarkupWidth(left) + 3 + MooStatusCellWidth + MarkupWidth(rightBar) + MinStatusGap + MeterSlack <= HeaderWidth()) + { + left = $"{left} {MooStatusCell(mooStatus)}"; + } + var room = HeaderWidth() - MarkupWidth(left) - MarkupWidth(rightBar) - MinStatusGap; foreach (var focusHint in FocusHints()) { @@ -12318,6 +13186,21 @@ public async ValueTask DisposeAsync() _inlineImages.Dispose(); _imageLoader.Dispose(); _fetcher.Dispose(); + foreach (var scripts in _scripts.Values) + { + scripts.Dispose(); // before the sessions, so no timer of theirs fires into a closed one + } + + _scripts.Clear(); + foreach (var (session, map) in _recorders) + { + SaveMap(session, map); + map.Dispose(); + } + + _recorders.Clear(); + DisposeSound(); + DisposePresence(); await _sessions.DisposeAsync().ConfigureAwait(false); _frameworkLog.Dispose(); if (_ownsDiagnostics) diff --git a/src/SharpMUTerm.Tui/SnapshotViews.cs b/src/SharpMUTerm.Tui/SnapshotViews.cs index b858b409..f8d76322 100644 --- a/src/SharpMUTerm.Tui/SnapshotViews.cs +++ b/src/SharpMUTerm.Tui/SnapshotViews.cs @@ -18,11 +18,12 @@ internal static class SnapshotViews "history", "history-search", "history-search-filter", "draft", "draft2", "menu", "menu-split", "messages", "quit", "connections", "characters", "tint", "tint-input", "tint-input-moved", "tint-tabs", - "compose", "compose-literal", "mssp", "mssp-none", "mssp-never", "web", + "compose", "compose-literal", "moo-edit", "mssp", "mssp-none", "mssp-never", "web", "rail-long", "scrollback", "scrollback-up", "links", "away", "away-scrollback", "search", "search-regex", "search-all", "search-landed", "activity-bar", "mxp-image", "mxp-figure", "input-selection", "selection", "prefix", "prefix-panel", "focus", "focus-moved", "tabs", "tabs-many", - "collapsed", "timestamps", "timestamps-toggled", "prompt", + "collapsed", "timestamps", "timestamps-toggled", "prompt", "meters", "gamedata", "map", "map-walking", "map-split", + "packages", "package-review", "package-browse", "send-menu", ]; } diff --git a/src/SharpMUTerm.Tui/SpellingHighlighter.cs b/src/SharpMUTerm.Tui/SpellingHighlighter.cs new file mode 100644 index 00000000..ed160a96 --- /dev/null +++ b/src/SharpMUTerm.Tui/SpellingHighlighter.cs @@ -0,0 +1,21 @@ +using SharpConsoleUI; +using SharpConsoleUI.Controls; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Tui; + +/// +/// Marks the words the composer's dictionary does not know, through the editor's syntax-highlighting hook. +/// +/// +/// A colour and not an underline: the hook carries a foreground and nothing else, and an underline needs a +/// styling hook in upstream. on +/// is the pair the settings screens already use for a refused value. +/// +internal sealed class SpellingHighlighter(SpellChecker checker) : ISyntaxHighlighter +{ + private static readonly Color Marked = new(ScreenPalette.Warn); + + public (IReadOnlyList Tokens, SyntaxLineState EndState) Tokenize(string line, int lineIndex, SyntaxLineState startState) => + (checker.Misspellings(line).Select(m => new SyntaxToken(m.Start, m.Length, Marked)).ToArray(), startState); +} diff --git a/src/SharpMUTerm.Tui/StatusMeterRenderer.cs b/src/SharpMUTerm.Tui/StatusMeterRenderer.cs new file mode 100644 index 00000000..e17a767c --- /dev/null +++ b/src/SharpMUTerm.Tui/StatusMeterRenderer.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; + +namespace SharpMUTerm.Tui; + +/// +/// The status row's meters: hp 450/500 mp 80/120, drawn from the session's game data. +/// +/// Every field is reserved. A value and its maximum each take cells whatever +/// they hold, because they arrive from the wire: a meter that grew a cell at 99 → 100 would widen the row, +/// and a status row past the width wraps and takes a row off every pane (the scrollback distance learned that +/// first). A number too long for its field is scaled (123k) rather than allowed to grow. +/// +/// +/// A meter with nothing to read is not drawn, so a world with no GMCP or MSDP costs the row nothing. +/// It appears once, when the first value arrives — a one-off change of the same kind as a spawn window +/// opening — and from then on its width is fixed. +/// +/// +internal static class StatusMeterRenderer +{ + /// The cells a value, and separately its maximum, always take. + internal const int ValueWidth = 5; + + /// Below this share of its maximum a value is drawn in the warning ink. + internal const double LowShare = 0.25; + + /// The meters that have a value, as one markup run, or empty when none does. + internal static string Render(IReadOnlyList meters, GameDataStore data, string lowInk) + { + var cells = new List(); + foreach (var meter in meters) + { + if (StatusMeter.Read(data, meter.Value) is not { } value) + { + continue; + } + + var max = string.IsNullOrWhiteSpace(meter.Max) ? null : StatusMeter.Read(data, meter.Max); + var low = IsLow(value, max); + var label = MarkupText.Escape(meter.Label); + var shown = MarkupText.Escape(StatusMeter.Fit(value, ValueWidth).PadLeft(ValueWidth)); + var cell = low + ? $"[dim]{label}[/] [bold {lowInk}]{shown}[/]" + : $"[dim]{label}[/] [bold]{shown}[/]"; + if (!string.IsNullOrWhiteSpace(meter.Max)) + { + cell += $"[dim]/{MarkupText.Escape(StatusMeter.Fit(max ?? string.Empty, ValueWidth).PadRight(ValueWidth))}[/]"; + } + + cells.Add(cell); + } + + return string.Join(" ", cells); + } + + private static bool IsLow(string value, string? max) => + max is not null + && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) + && double.TryParse(max, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) + && m > 0 + && v / m < LowShare; +} diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs index 3611ace3..1e6e93c7 100644 --- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs @@ -62,6 +62,51 @@ internal static class WorldsScreenRenderer internal const int KeepaliveField = 4; + /// + /// : the GMCP modules asked for beyond the client's own, as a + /// comma-separated list of Name Version. + /// + internal const int GmcpModulesField = 5; + + /// , as . + internal const int ContentField = 6; + + /// 's three switches, as one of . + internal const int EmojiField = 7; + + /// : the line naming a room, for a game that sends no GMCP room. + internal const int MapRoomField = 8; + + /// : the line listing a room's exits. + internal const int MapExitsField = 9; + + /// : a line that stops a speedwalk. + internal const int WalkFailField = 10; + + /// , as one of . + internal const int SoundsField = 11; + + /// : the line prefix a MUSH hands text back to edit with. + internal const int GrabField = 12; + + /// , as one of . + internal const int GmcpLoginField = 13; + + /// , as one of . + internal const int DiscordField = 14; + + /// F5's words for . + internal static readonly string[] DiscordChoices = ["off", "show status"]; + + /// F5's words for : how a character with a saved password logs in. + internal static readonly string[] LoginChoices = ["off", "if offered"]; + + /// The prefix the grab row suggests while it is off: AresMUSH's, and the one its /edit commands use. + internal const string SuggestedGrabPrefix = "FugueEdit >"; + + /// F5's words for : on fetches what the game asks for. + internal static readonly string[] SoundChoices = ["download", "cached only"]; + /// /// The character row's field ordinals, in the order ⇥ steps through them — which is the order the /// CHARACTER form draws them, top to bottom. The name leads, as on every list screen. The password @@ -77,6 +122,13 @@ internal static class WorldsScreenRenderer internal const int OnConnectField = 3; + /// + /// , labelled disconnect as the counterpart of the + /// connect line (and because on disconnect is wider than the form's label column). Inserted + /// under the on-connect lines, not appended, so ⇥ reads the form top to bottom (the password's rule). + /// + internal const int OnDisconnectField = 4; + /// /// , placed with the connection rows it belongs to: /// the connect line, the on-connect lines and the login readout all answer "what happens when this @@ -87,11 +139,11 @@ internal static class WorldsScreenRenderer /// all. This one has a well, and ⏎ opens it. /// /// - internal const int StartupField = 4; + internal const int StartupField = 5; - internal const int LogFormatField = 5; + internal const int LogFormatField = 6; - internal const int LogDirectoryField = 6; + internal const int LogDirectoryField = 7; /// /// — whether this character's panes come back holding their @@ -99,7 +151,7 @@ internal static class WorldsScreenRenderer /// because it is the third answer to "what does this client write down about me". A field and not a /// toggle for 's reason. /// - internal const int RestoreLogField = 7; + internal const int RestoreLogField = 8; /// /// — the colour this character's panes are painted in. @@ -107,7 +159,7 @@ internal static class WorldsScreenRenderer /// numbering reason above and because it is the one row on this form that is about /// appearance: it belongs at the end, where a reader stops looking for behaviour. /// - internal const int PaneTintField = 8; + internal const int PaneTintField = 9; /// /// The trigger-set row's field ordinals. A set's name leads, as every list row's does — and here it @@ -118,6 +170,15 @@ internal static class WorldsScreenRenderer internal const int SetDescriptionField = 1; + /// + /// , typed as a comma-separated list of names under the scripts + /// folder. It used to be reachable only by editing config.json, and nothing ran the files anyway. + /// + internal const int SetScriptsField = 2; + + /// What a set with no script files shows in its scripts well. + internal const string NoScripts = "—"; + /// The F-key that opens this screen, and the second key that opens it on a character's log. internal const string FKey = "F5"; @@ -397,7 +458,29 @@ internal static ScreenModel Model( ScreenField.Text("host", () => w.Host, v => w.Host = v), ScreenField.Integer("port", () => w.Port, v => w.Port = v, 1, 65535), ScreenField.Choice("encoding", () => w.Encoding, v => w.Encoding = v, Encodings), - ScreenField.Integer("keepalive", () => w.KeepaliveSeconds, v => w.KeepaliveSeconds = v, 0, 86400))) + ScreenField.Integer("keepalive", () => w.KeepaliveSeconds, v => w.KeepaliveSeconds = v, 0, 86400), + ScreenField.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v)), + ScreenField.Choice("content", () => ContentName(w.ContentFormat), v => w.ContentFormat = ContentOf(v), ContentChoices), + ScreenField.Choice("emoji", () => EmojiName(w.Emoji), v => SetEmoji(w.Emoji, v), EmojiChoices), + ScreenField.OptionalPattern("map room", () => w.Map.RoomNamePattern, v => w.Map.RoomNamePattern = v), + ScreenField.OptionalPattern("map exits", () => w.Map.ExitsPattern, v => w.Map.ExitsPattern = v), + ScreenField.OptionalPattern("walk fail", () => w.Map.WalkFailurePattern, v => w.Map.WalkFailurePattern = v), + ScreenField.Choice( + "sounds", + () => w.DownloadMedia ? SoundChoices[0] : SoundChoices[1], + v => w.DownloadMedia = v == SoundChoices[0], + SoundChoices), + ScreenField.Optional("edit grab", () => w.GrabPrefix, v => w.GrabPrefix = v), + ScreenField.Choice( + "gmcp login", + () => w.GmcpLogin ? LoginChoices[1] : LoginChoices[0], + v => w.GmcpLogin = v == LoginChoices[1], + LoginChoices), + ScreenField.Choice( + "discord", + () => w.DiscordPresence ? DiscordChoices[1] : DiscordChoices[0], + v => w.DiscordPresence = v == DiscordChoices[1], + DiscordChoices))) .Concat(WorldButtons(worlds, selectedWorld, info)) .ToArray(); @@ -415,6 +498,7 @@ internal static ScreenModel Model( ScreenField.Defaulted( "connect", () => c.ConnectString, v => c.ConnectString = v, ConnectStringTemplate.Default), ScreenField.Optional("on connect", () => c.OnConnect, v => c.OnConnect = v), + ScreenField.Optional("disconnect", () => c.OnDisconnect, v => c.OnDisconnect = v), ScreenField.Choice( StartupLabel, () => c.ConnectAtStartup ? StartupOn : StartupOff, @@ -449,7 +533,8 @@ internal static ScreenModel Model( () => set.Name, value => RenameSet(worlds, set, value), triggerSets.Where(s => !ReferenceEquals(s, set)).Select(s => s.Name).ToArray()), - ScreenField.Optional("description", () => set.Description, v => set.Description = v))) + ScreenField.Optional("description", () => set.Description, v => set.Description = v), + ScreenField.Optional("scripts", () => JoinScripts(set), v => SplitScripts(set, v)))) .Concat(SetButtons(worlds, triggerSets, selectedSet)) .ToArray(); @@ -495,6 +580,50 @@ internal static ScreenModel Model( } }); + /// A world's extra GMCP modules as the well shows and edits them, or null for none. + internal static string? JoinModules(WorldDefinition world) => + world.GmcpModules.Count == 0 ? null : string.Join(", ", world.GmcpModules); + + /// + /// Writes the well back: split on commas, and only entries accepts are + /// kept, normalised to Name Version, so what is saved is what will be sent. + /// + private static string PatternDetail(string? pattern, string unset) => + string.IsNullOrWhiteSpace(pattern) ? $"[{Label}]{unset}[/]" : $"[{Value}]{Escape(pattern)}[/]"; + + internal static void SplitModules(WorldDefinition world, string? value) + { + world.GmcpModules.Clear(); + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (GmcpHandshake.Parse(part) is { } module + && !world.GmcpModules.Any(m => m.StartsWith(module.Name + " ", StringComparison.OrdinalIgnoreCase))) + { + world.GmcpModules.Add($"{module.Name} {module.Version.ToString(CultureInfo.InvariantCulture)}"); + } + } + } + + /// A set's script files as the well shows and edits them, or null for none. + internal static string? JoinScripts(TriggerSet set) => + set.ScriptFiles.Count == 0 ? null : string.Join(", ", set.ScriptFiles); + + /// + /// Writes the well back as a list: split on commas, trimmed, blanks and repeats dropped, order kept — + /// the order files load in, so a later file can rely on what an earlier one defined. + /// + internal static void SplitScripts(TriggerSet set, string? value) + { + set.ScriptFiles.Clear(); + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!set.ScriptFiles.Contains(part, StringComparer.Ordinal)) + { + set.ScriptFiles.Add(part); + } + } + } + /// /// Renames a set and every reference to it — the one rename on these screens that reaches /// outside the object it is on. A character selects automation by name, so a set renamed alone would @@ -850,6 +979,55 @@ internal static List DetailColumn( cursor, selectedWorld, KeepaliveField)), + WorldField("gmcp", Field( + JoinModules(world) is { } modules + ? $"[{Value}]{Escape(modules)}[/]" + : $"[{Label}]defaults[/]", + cursor, + selectedWorld, + GmcpModulesField)), + WorldField("content", Field( + world.ContentFormat == ContentFormat.Ansi + ? $"[{Label}]{ContentName(world.ContentFormat)}[/]" + : $"[{Value}]{ContentName(world.ContentFormat)}[/]", + cursor, + selectedWorld, + ContentField)), + WorldField("emoji", Field( + world.Emoji.Enabled + ? $"[{Value}]{EmojiName(world.Emoji)}[/]" + : $"[{Label}]{EmojiOff}[/]", + cursor, + selectedWorld, + EmojiField)), + WorldField("map room", Field( + PatternDetail(world.Map.RoomNamePattern, "from GMCP"), cursor, selectedWorld, MapRoomField)), + WorldField("map exits", Field( + PatternDetail(world.Map.ExitsPattern, "from GMCP"), cursor, selectedWorld, MapExitsField)), + WorldField("walk fail", Field( + PatternDetail(world.Map.WalkFailurePattern, "timeout only"), cursor, selectedWorld, WalkFailField)), + WorldField("sounds", Field( + world.DownloadMedia ? $"[{Label}]{SoundChoices[0]}[/]" : $"[{Value}]{SoundChoices[1]}[/]", + cursor, + selectedWorld, + SoundsField)), + WorldField("edit grab", Field( + world.GrabPrefix is { } grab + ? $"[{Value}]{Escape(grab)}[/]" + : $"[{Label}]off · e.g. {Escape(SuggestedGrabPrefix)}[/]", + cursor, + selectedWorld, + GrabField)), + WorldField("gmcp login", Field( + world.GmcpLogin ? $"[{Value}]{LoginChoices[1]}[/]" : $"[{Label}]{LoginChoices[0]} · connect line[/]", + cursor, + selectedWorld, + GmcpLoginField)), + WorldField("discord", Field( + world.DiscordPresence ? $"[{Value}]{DiscordChoices[1]}[/]" : $"[{Label}]{DiscordChoices[0]}[/]", + cursor, + selectedWorld, + DiscordField)), string.Empty, $"[{accent}]├ CHARACTERS[/] [{Label}]a character is a connection[/]", }); @@ -1000,6 +1178,12 @@ internal static List FormColumn( selectedCharacter, OnConnectField, pane: CharactersPane)), + CharField("disconnect", Field( + $"[{Value}]{Escape(character.OnDisconnect ?? "—")}[/]", + cursor, + selectedCharacter, + OnDisconnectField, + pane: CharactersPane)), CharField(StartupLabel, Field( StartupDetail(character.ConnectAtStartup), cursor, @@ -1045,6 +1229,55 @@ internal static List FormColumn( return ScreenChrome.Choices(form, cursor.Edit, CharDetailColumnWidth); } + /// + /// The content field's words. auto is , the stored default, + /// which starts on ANSI and follows a negotiated MXP; ansi is the deliberate choice not to. + /// + internal static readonly IReadOnlyList ContentChoices = ["auto", "ansi", "mxp", "pueblo"]; + + internal static string ContentName(ContentFormat format) => format switch + { + ContentFormat.AnsiOnly => "ansi", + ContentFormat.Mxp => "mxp", + ContentFormat.Pueblo => "pueblo", + _ => "auto", + }; + + internal static ContentFormat ContentOf(string name) => name.ToLowerInvariant() switch + { + "ansi" => ContentFormat.AnsiOnly, + "mxp" => ContentFormat.Mxp, + "pueblo" => ContentFormat.Pueblo, + _ => ContentFormat.Ansi, + }; + + internal const string EmojiOff = "off"; + + /// + /// The emoji field's words: which of the two substitutions run. Three switches in the + /// configuration, one field here, because "on, with neither kind" is not a state anybody wants. + /// + internal static readonly IReadOnlyList EmojiChoices = [EmojiOff, "emoticons", "shortcodes", "both"]; + + internal static string EmojiName(EmojiSettings emoji) => (emoji.Enabled, emoji.Emoticons, emoji.Shortcodes) switch + { + (false, _, _) or (true, false, false) => EmojiOff, + (true, true, false) => "emoticons", + (true, false, true) => "shortcodes", + _ => "both", + }; + + internal static void SetEmoji(EmojiSettings emoji, string name) + { + var choice = name.ToLowerInvariant(); + emoji.Enabled = choice != EmojiOff; + if (emoji.Enabled) + { + emoji.Emoticons = choice is "emoticons" or "both"; + emoji.Shortcodes = choice is "shortcodes" or "both"; + } + } + /// /// What an unset log directory reads as. The field itself holds null — the logger picks a /// per-session folder under the config directory — so the row names the state rather than showing @@ -1145,6 +1378,7 @@ internal static List TriggersColumn( // each row can be read down. Measured over the drawn text, since a name may carry brackets. var nameWidth = Width(triggerSets, s => "▪ " + Escape(s.Name)); var noteWidth = Width(triggerSets, s => Escape(s.Description ?? NoDescription)); + var scriptWidth = Width(triggerSets, s => Escape(JoinScripts(s) ?? NoScripts)); var rows = new List(triggerSets.Count); foreach (var set in triggerSets) @@ -1162,8 +1396,11 @@ internal static List TriggersColumn( var note = ScreenChrome.Field( PadVisible($"[{Label}]{Escape(set.Description ?? NoDescription)}[/]", noteWidth), cursor.EditOn(TriggerSetsPane, index, SetDescriptionField)); + var scripts = ScreenChrome.Field( + PadVisible($"[{Label}]{Escape(JoinScripts(set) ?? NoScripts)}[/]", scriptWidth), + cursor.EditOn(TriggerSetsPane, index, SetScriptsField)); - rows.Add($"{box} {name} {note} [{Label}]{Inventory(set)}[/]"); + rows.Add($"{box} {name} {note} {scripts} [{Label}]{Inventory(set)}[/]"); } // The checklist sits in an auto-width column (see WorldsScreenView), so a cursor bar sized to diff --git a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs index f362794b..858631dc 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs @@ -46,7 +46,7 @@ public async Task EveryViewDrawsTheSameCellsInTheWindowAsInTheTerminal(string na using var driver = new AvaloniaConsoleDriver(Width, Height); await using (var window = new SharpMUTermApp( DemoScene.Build(), - WindowHost.Create(driver, clipboard: null) with { Capabilities = Caps, Keys = HostKeys.Terminal, RunsInline = true })) + WindowHost.Create(driver, clipboard: null) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, RunsInline = true })) { window.RenderSnapshot(view); } @@ -83,7 +83,7 @@ public async Task EveryViewDrawsTheSameCellsWhenTheWindowsLoopIsRunning(string n using var driver = new AvaloniaConsoleDriver(Width, Height); await using var window = new SharpMUTermApp( DemoScene.Build(), - WindowHost.Create(driver, clipboard: null) with { Capabilities = Caps, Keys = HostKeys.Terminal }); + WindowHost.Create(driver, clipboard: null) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null }); window.OnUiThread(() => _ = window.RenderSnapshot(view)); var loop = new Thread(() => window.Run([])) { IsBackground = true }; loop.Start(); diff --git a/tests/SharpMUTerm.Core.Tests/Commands/CompletionIndexTests.cs b/tests/SharpMUTerm.Core.Tests/Commands/CompletionIndexTests.cs new file mode 100644 index 00000000..550bd6f2 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Commands/CompletionIndexTests.cs @@ -0,0 +1,57 @@ +using SharpMUTerm.Core.Commands; + +namespace SharpMUTerm.Core.Tests.Commands; + +public class CompletionIndexTests +{ + // Newest first, and a later word on a line is newer than an earlier one. + [Test] + public async Task WordsAreOfferedNewestFirstWithoutRegardToCase() + { + var index = new CompletionIndex(); + index.ObserveLine("The gatehouse looms."); + index.ObserveLine("Gandalf arrives from the gate."); + + await Assert.That(index.Candidates("ga")).IsEquivalentTo( + new[] { "gate", "Gandalf", "gatehouse" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task NamesComeBeforeWords() + { + var index = new CompletionIndex(); + index.ObserveLine("A galleon sails past."); + index.ObserveGmcp("Room.Players", """[{"name":"Galadriel","fullname":"Galadriel the Fair"}]"""); + index.ObserveGmcp("Comm.Channel.Text", """{"channel":"ooc","talker":"Gimli","text":"hi"}"""); + + await Assert.That(index.Candidates("g")).IsEquivalentTo( + new[] { "Gimli", "Galadriel", "galleon" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task ShortWordsTheTypedPrefixAndMalformedGmcpAreLeftOut() + { + var index = new CompletionIndex(); + index.ObserveLine("go to it, gondolier"); + index.ObserveGmcp("Room.Players", "{not json"); + index.ObserveGmcp("Char.Vitals", """{"name":"Gollum"}"""); + + await Assert.That(index.Candidates("go")).IsEquivalentTo(new[] { "gondolier" }); + await Assert.That(index.Candidates("gondolier")).IsEmpty(); + await Assert.That(index.Candidates(string.Empty)).IsEmpty(); + } + + [Test] + public async Task TheOldestWordGoesPastTheCap() + { + var index = new CompletionIndex(); + index.ObserveLine("zephyr"); + for (var i = 0; i < CompletionIndex.MaxWords; i++) + { + index.ObserveLine($"word{i:D5}x"); + } + + await Assert.That(index.Candidates("zep")).IsEmpty(); + await Assert.That(index.Candidates("word00001")).IsNotEmpty(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/ManualTimeProvider.cs b/tests/SharpMUTerm.Core.Tests/Mapping/ManualTimeProvider.cs new file mode 100644 index 00000000..c825faad --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/ManualTimeProvider.cs @@ -0,0 +1,82 @@ +namespace SharpMUTerm.Core.Tests.Mapping; + +/// +/// A clock and timer source a test moves by hand. A copy of the Tui suite's, so the speedwalk timeout can be asserted +/// instead of waited on: a limiter tested with real time is a test that sleeps for its interval and +/// still races the machine it runs on. moves the clock and fires every timer +/// whose due time it passes, on the calling thread, so a trailing flush lands before the next line +/// of the test. +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly List _timers = new(); + private DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state); + _timers.Add(timer); + timer.Change(dueTime, period); + return timer; + } + + /// Moves the clock on by , firing every timer that comes due. + public void Advance(TimeSpan by) + { + _now += by; + + // A firing callback may re-arm its own timer (the flush does, when a session is still inside + // its interval), so fire from a snapshot and let the re-arm wait for the next Advance. + foreach (var timer in _timers.ToArray()) + { + timer.FireIfDue(_now); + } + } + + private void Forget(ManualTimer timer) => _timers.Remove(timer); + + private sealed class ManualTimer : ITimer + { + private readonly ManualTimeProvider _owner; + private readonly TimerCallback _callback; + private readonly object? _state; + private DateTimeOffset? _dueAt; + + public ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) + { + _owner = owner; + _callback = callback; + _state = state; + } + + /// Arms (or, with an infinite due time, disarms) the timer. Periods are unused here. + public bool Change(TimeSpan dueTime, TimeSpan period) + { + _dueAt = dueTime == Timeout.InfiniteTimeSpan ? null : _owner.GetUtcNow() + dueTime; + return true; + } + + public void FireIfDue(DateTimeOffset now) + { + if (_dueAt is { } due && now >= due) + { + _dueAt = null; + _callback(_state); + } + } + + public void Dispose() + { + _dueAt = null; + _owner.Forget(this); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/MapRecorderTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/MapRecorderTests.cs new file mode 100644 index 00000000..3a930d00 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/MapRecorderTests.cs @@ -0,0 +1,107 @@ +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class MapRecorderTests +{ + private static RoomObservation Room(long? id, string name, params (string Key, long? To)[] exits) => + new(id, name, exits.Select(e => new ObservedExit(e.Key, e.To)).ToList()); + + [Test] + public async Task ServerRoomsAreAddedLinkedAndPlacedByTheWayWalked() + { + var recorder = new MapRecorder(new WorldMap()); + + recorder.Apply(Room(1, "Hall", ("north", 2))); + recorder.NoteCommand("n"); + recorder.Apply(Room(2, "Gallery", ("south", 1), ("up", 3))); + recorder.NoteCommand("say hello"); // not a move + recorder.NoteCommand("u"); + recorder.Apply(Room(3, "Attic")); + + var map = recorder.Map; + await Assert.That(recorder.CurrentRoomId).IsEqualTo(3L); + await Assert.That((map.Room(1)!.X, map.Room(1)!.Y)).IsEqualTo((0, 0)); + await Assert.That((map.Room(2)!.X, map.Room(2)!.Y, map.Room(2)!.Z)).IsEqualTo((0, -1, 0)); + await Assert.That(map.Room(3)!.Z).IsEqualTo(1); + await Assert.That(map.Room(2)!.Exits["up"]).IsEqualTo(3L); + await Assert.That(recorder.IsDirty).IsTrue(); + } + + [Test] + public async Task ServerCoordinatesWin() + { + var recorder = new MapRecorder(new WorldMap()); + recorder.Apply(Room(1, "Hall") with { Coordinates = (10, 20, 0) }); + recorder.NoteCommand("e"); + recorder.Apply(Room(2, "Yard") with { Coordinates = (40, 2, 0) }); + + await Assert.That((recorder.Map.Room(2)!.X, recorder.Map.Room(2)!.Y)).IsEqualTo((40, 2)); + } + + /// A room walked into a cell another room holds is pushed on along the same direction. + [Test] + public async Task ACollisionIsNudgedAlongTheDirection() + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 50, X = 1, Y = 0 }); + var recorder = new MapRecorder(map); + + recorder.Apply(Room(1, "Hall")); + recorder.NoteCommand("east"); + recorder.Apply(Room(2, "Annex")); + + await Assert.That((map.Room(2)!.X, map.Room(2)!.Y)).IsEqualTo((2, 0)); + } + + [Test] + public async Task ASpecialExitIsLearnedAndPlacedBesideTheRoomBefore() + { + var recorder = new MapRecorder(new WorldMap()); + recorder.Apply(Room(1, "Hall", ("enter portal", null))); + recorder.NoteCommand("enter portal"); + recorder.Apply(Room(9, "Elsewhere")); + + var hall = recorder.Map.Room(1)!; + await Assert.That(hall.SpecialExits["enter portal"]).IsEqualTo(9L); + await Assert.That(hall.Stubs).IsEmpty(); + var elsewhere = recorder.Map.Room(9)!; + await Assert.That(Math.Max(Math.Abs(elsewhere.X), Math.Abs(elsewhere.Y))).IsEqualTo(1); + } + + /// + /// The text source gives no numbers. Walking out and back finds the first room again through the link the + /// walk made, rather than adding a second copy of it; a look finds the same room by name. + /// + [Test] + public async Task TextRoomsAreRecognisedByTheWayBack() + { + var recorder = new MapRecorder(new WorldMap()); + + recorder.Apply(Room(null, "Square", ("north", null))); + recorder.NoteCommand("n"); + recorder.Apply(Room(null, "Market", ("south", null))); + recorder.NoteCommand("s"); + recorder.Apply(Room(null, "Square", ("north", null))); + recorder.Apply(Room(null, "Square", ("north", null))); // look + + var map = recorder.Map; + await Assert.That(map.Rooms.Count).IsEqualTo(2); + await Assert.That(recorder.CurrentRoom!.Name).IsEqualTo("Square"); + await Assert.That(map.Room(1)!.Exits["north"]).IsEqualTo(2L); + await Assert.That(map.Room(2)!.Exits["south"]).IsEqualTo(1L); + await Assert.That(PathFinder.Find(map, 1, 2)!.Single().Command).IsEqualTo("n"); + } + + [Test] + public async Task AnExitsUpdateChangesTheCurrentRoomWithoutMoving() + { + var recorder = new MapRecorder(new WorldMap()); + recorder.Apply(Room(1, "Hall", ("north", 2))); + + recorder.Apply(Room(null, string.Empty, ("east", 3)) with { ExitsOnly = true }); + + await Assert.That(recorder.CurrentRoomId).IsEqualTo(1L); + await Assert.That(recorder.Map.Room(1)!.Exits.Keys).IsEquivalentTo(new[] { "east" }); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs new file mode 100644 index 00000000..e7e8b39d --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/MapStoreTests.cs @@ -0,0 +1,144 @@ +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class MapStoreTests +{ + private static string TempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "sharpmuterm-maps-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + [Test] + public async Task AMapRoundTrips() + { + var directory = TempDirectory(); + try + { + var store = new MapStore(directory); + var map = store.For("Mud.Example.ORG.", 4000); + var area = map.AreaNamed("Town"); + map.Add(new MapRoom + { + Id = 7, + Name = "Square", + AreaId = area.Id, + X = 1, + Y = -2, + Z = 3, + Environment = "city", + Exits = { ["north"] = 8 }, + SpecialExits = { ["enter fountain"] = 9 }, + Weight = 4, + ExitWeights = { ["north"] = 2 }, + LockedExits = { "enter fountain" }, + Locked = true, + Symbol = "$", + Color = "#ff8800", + UserData = { ["note"] = "bank" }, + }); + store.Save("mud.example.org", 4000); + + await Assert.That(File.Exists(Path.Combine(directory, "mud.example.org_4000.json"))).IsTrue(); + + var loaded = new MapStore(directory).For("mud.example.org", 4000); + var room = loaded.Room(7)!; + await Assert.That(loaded.Areas[area.Id].Name).IsEqualTo("Town"); + await Assert.That((room.Name, room.AreaId, room.X, room.Y, room.Z)).IsEqualTo(("Square", (int?)area.Id, 1, -2, 3)); + await Assert.That(room.Exits["north"]).IsEqualTo(8L); + await Assert.That(room.SpecialExits["enter fountain"]).IsEqualTo(9L); + await Assert.That((room.Weight, room.ExitWeights["north"], room.Locked)).IsEqualTo((4, 2, true)); + await Assert.That(room.LockedExits).Contains("enter fountain"); + await Assert.That((room.Symbol, room.Color, room.UserData["note"])).IsEqualTo(("$", "#ff8800", "bank")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task AStoreWithNoDirectoryWritesNothing() + { + var store = new MapStore(); + store.For("h", 1).Add(new MapRoom { Id = 1 }); + store.Save("h", 1); + + await Assert.That(store.IsPersistent).IsFalse(); + await Assert.That(store.For("h", 1).Room(1)).IsNotNull(); + } + + /// An older client never destroys a newer client's map: it loads empty, says why, and does not write. + [Test] + public async Task ANewerFileIsLeftAlone() + { + var directory = TempDirectory(); + try + { + var path = Path.Combine(directory, MapStore.FileNameFor(MapStore.Key("h", 1))); + var newer = """{"version":99,"rooms":[{"id":1,"name":"Future"}]}"""; + File.WriteAllText(path, newer); + + var store = new MapStore(directory); + var map = store.For("h", 1); + map.Add(new MapRoom { Id = 2 }); + store.Save("h", 1); + + await Assert.That(map.Rooms.Count).IsEqualTo(1); + await Assert.That(store.ProblemFor("h", 1)).Contains("newer"); + await Assert.That(File.ReadAllText(path)).IsEqualTo(newer); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public async Task AnUnversionedFileIsTheFirstLayoutAndBadExitsAreDropped() + { + var map = MapStore.Deserialize("""{"rooms":[{"id":1,"name":"Old","weight":0,"exits":{"N":2,"sideways":3}}]}"""); + + var room = map.Room(1)!; + await Assert.That(room.Exits.Keys).IsEquivalentTo(new[] { "north" }); + await Assert.That(room.Weight).IsEqualTo(1); + } + + [Test] + public async Task AnUnreadableFileIsMovedAsideAndMappingStartsAgain() + { + var directory = TempDirectory(); + try + { + var path = Path.Combine(directory, MapStore.FileNameFor(MapStore.Key("h", 1))); + File.WriteAllText(path, "not json"); + + var store = new MapStore(directory); + store.For("h", 1).Add(new MapRoom { Id = 5 }); + store.Save("h", 1); + + await Assert.That(File.ReadAllText(path + MapStore.AsideSuffix)).IsEqualTo("not json"); + await Assert.That(new MapStore(directory).For("h", 1).Room(5)).IsNotNull(); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Test] + [Arguments("../../etc/passwd", 23)] + [Arguments("..", 1)] + [Arguments("a/b\\c", 2)] + public async Task AHostCannotNameAPathOutsideTheFolder(string host, int port) + { + var name = MapStore.FileNameFor(MapStore.Key(host, port)); + + await Assert.That(name).DoesNotContain("/"); + await Assert.That(name).DoesNotContain("\\"); + await Assert.That(name.StartsWith('.')).IsFalse(); + await Assert.That(Path.GetFileName(name)).IsEqualTo(name); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs new file mode 100644 index 00000000..6947366f --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/PathFinderTests.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class PathFinderTests +{ + /// A row of rooms 1 → 2 → … → , each east of the last, with exits both ways. + private static WorldMap Corridor(int count) + { + var map = new WorldMap(); + for (var id = 1; id <= count; id++) + { + var room = map.Add(new MapRoom { Id = id, Name = $"Room {id}", X = id }); + if (id > 1) + { + room.Exits["west"] = id - 1; + } + + if (id < count) + { + room.Exits["east"] = id + 1; + } + } + + return map; + } + + [Test] + public async Task TheShortestWalkIsItsCommands() + { + var map = Corridor(4); + + var path = PathFinder.Find(map, 1, 4); + + await Assert.That(path!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "e", "e" }); + await Assert.That(path!.Select(s => s.To)).IsEquivalentTo(new long[] { 2, 3, 4 }); + await Assert.That(PathFinder.Find(map, 4, 4)).IsEmpty(); + } + + /// A square: 1 east to 2 south to 4, or 1 south to 3 east to 4. A heavy room 2 sends the walk round by 3. + [Test] + public async Task AHeavyRoomIsWalkedAround() + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, Exits = { ["east"] = 2, ["south"] = 3 } }); + map.Add(new MapRoom { Id = 2, Exits = { ["south"] = 4 } }); + map.Add(new MapRoom { Id = 3, Exits = { ["east"] = 4 } }); + map.Add(new MapRoom { Id = 4 }); + + map.Room(3)!.Weight = 5; + await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "s" }); + + map.Room(3)!.Weight = 1; + map.Room(2)!.Weight = 5; + await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "s", "e" }); + + // An exit's own weight wins over the room it enters. + map.Room(1)!.ExitWeights["east"] = 1; + map.Room(2)!.ExitWeights["south"] = 1; + map.Room(1)!.ExitWeights["south"] = 3; + await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "e", "s" }); + } + + [Test] + public async Task LockedRoomsAndExitsAreNeverTaken() + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, Exits = { ["east"] = 2, ["south"] = 3 } }); + map.Add(new MapRoom { Id = 2, Exits = { ["south"] = 4 } }); + map.Add(new MapRoom { Id = 3, Exits = { ["east"] = 4 } }); + map.Add(new MapRoom { Id = 4 }); + + map.Room(2)!.Locked = true; + await Assert.That(PathFinder.Find(map, 1, 4)!.Select(s => s.Command)).IsEquivalentTo(new[] { "s", "e" }); + + map.Room(1)!.LockedExits.Add("south"); + await Assert.That(PathFinder.Find(map, 1, 4)).IsNull(); + + map.Room(4)!.Locked = true; + await Assert.That(PathFinder.Find(map, 3, 4)).IsNull(); + } + + [Test] + public async Task SpecialExitsAreWalkedByTheirCommand() + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, SpecialExits = { ["enter portal"] = 9 } }); + map.Add(new MapRoom { Id = 9 }); + + await Assert.That(PathFinder.Find(map, 1, 9)!.Single().Command).IsEqualTo("enter portal"); + } + + [Test] + public async Task ARoomNoExitReachesIsUnreachable() + { + var map = Corridor(3); + map.Add(new MapRoom { Id = 50 }); + map.Room(3)!.Exits["north"] = 999; // leads to a room the map does not hold + + await Assert.That(PathFinder.Find(map, 1, 50)).IsNull(); + await Assert.That(PathFinder.Find(map, 1, 999)).IsNull(); + await Assert.That(PathFinder.Find(map, 777, 1)).IsNull(); + } + + /// A 100×100 grid, corner to corner: 198 steps, inside a budget a speedwalk can afford per keystroke. + [Test] + public async Task TenThousandRoomsIsQuick() + { + const int side = 100; + var map = new WorldMap(); + long Id(int x, int y) => (y * side) + x + 1; + for (var y = 0; y < side; y++) + { + for (var x = 0; x < side; x++) + { + var room = map.Add(new MapRoom { Id = Id(x, y), X = x, Y = y }); + if (x > 0) room.Exits["west"] = Id(x - 1, y); + if (x < side - 1) room.Exits["east"] = Id(x + 1, y); + if (y > 0) room.Exits["north"] = Id(x, y - 1); + if (y < side - 1) room.Exits["south"] = Id(x, y + 1); + } + } + + PathFinder.Find(map, 1, 2); // warm up the JIT, not the map + var clock = Stopwatch.StartNew(); + var path = PathFinder.Find(map, Id(0, 0), Id(side - 1, side - 1)); + clock.Stop(); + + await Assert.That(path!.Count).IsEqualTo((2 * side) - 2); + await Assert.That(clock.ElapsedMilliseconds).IsLessThan(500); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/RoomSourceTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/RoomSourceTests.cs new file mode 100644 index 00000000..befdd382 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/RoomSourceTests.cs @@ -0,0 +1,112 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Tests.Session; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class RoomSourceTests +{ + [Test] + public async Task IronRealmsRoomInfo() + { + var room = GmcpRoomSource.Read("Room.Info", """ + {"num":12345,"name":"A shady lane","area":"Ashtan","environment":"Urban", + "coords":"45,5,-4,1","map":"www.example.com/map.php?45","exits":{"n":12344,"se":12350},"details":["shop"]} + """)!; + + await Assert.That(room.Id).IsEqualTo(12345L); + await Assert.That((room.Name, room.Area, room.Environment)).IsEqualTo(("A shady lane", "Ashtan", "Urban")); + await Assert.That(room.Coordinates).IsEqualTo((5, -4, 1)); + await Assert.That(room.Exits).IsEquivalentTo(new[] { new ObservedExit("north", 12344), new ObservedExit("southeast", 12350) }); + } + + [Test] + public async Task AardwolfRoomInfo() + { + var room = GmcpRoomSource.Read("room.info", """ + {"num":32418,"name":"The Grand City of Aylor","zone":"aylor","terrain":"city", + "details":"","exits":{"n":32416,"e":32419},"coord":{"id":0,"x":30,"y":19,"cont":0}} + """)!; + + await Assert.That(room.Id).IsEqualTo(32418L); + await Assert.That((room.Area, room.Environment)).IsEqualTo(("aylor", "city")); + await Assert.That(room.Coordinates).IsEqualTo((30, 19, 0)); + await Assert.That(room.Exits.Count).IsEqualTo(2); + } + + [Test] + public async Task MumeRoomInfoAndUpdateExits() + { + var room = GmcpRoomSource.Read("Room.Info", """ + {"id":77,"name":"Ford","area":"Shire","terrain":"river", + "exits":{"north":{"id":78,"name":"","flags":["door"]},"west":{"name":"","flags":[]}}} + """)!; + + await Assert.That(room.Id).IsEqualTo(77L); + await Assert.That(room.Environment).IsEqualTo("river"); + await Assert.That(room.Exits).IsEquivalentTo(new[] { new ObservedExit("north", 78), new ObservedExit("west", null) }); + + var update = GmcpRoomSource.Read("Room.UpdateExits", """{"east":{"id":79}}""")!; + await Assert.That(update.ExitsOnly).IsTrue(); + await Assert.That(update.Exits.Single()).IsEqualTo(new ObservedExit("east", 79)); + } + + [Test] + [Arguments("Char.Vitals", """{"hp":1}""")] + [Arguments("Room.Info", "not json")] + [Arguments("Room.Info", """{"name":"no number"}""")] + public async Task NotARoom(string package, string json) => + await Assert.That(GmcpRoomSource.Read(package, json)).IsNull(); + + [Test] + public async Task TheTextSourceReadsANameAndItsExits() + { + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => new FakeTelnetSession()); + using var source = new TextRoomSource(session, new MapSettings + { + RoomNamePattern = @"^\*\* (?.+) \*\*$", + ExitsPattern = @"^\[Exits: (?.*)\]$", + }); + var seen = new List(); + source.RoomEntered += (_, room) => seen.Add(room); + + source.Observe("** Town Square **"); + source.Observe("A fountain splashes."); + source.Observe("[Exits: north, e and enter portal]"); + source.Observe("[Exits: none]"); + + await Assert.That(seen.Count).IsEqualTo(2); + await Assert.That(seen[0].Name).IsEqualTo("Town Square"); + await Assert.That(seen[0].Id).IsNull(); + await Assert.That(seen[0].Exits.Select(e => e.Key)).IsEquivalentTo(new[] { "north", "east", "enter", "portal" }); + await Assert.That(seen[1].Exits).IsEmpty(); + } + + [Test] + public async Task ABadPatternSwitchesTheTextSourceOffAndSaysSo() + { + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => new FakeTelnetSession()); + using var source = new TextRoomSource(session, new MapSettings { ExitsPattern = "([" }); + + await Assert.That(source.IsActive).IsFalse(); + await Assert.That(source.Problem).Contains("exits pattern"); + } + + /// The mapper learns which way a room was left from what the player sent; the login line never reaches it. + [Test] + public async Task CommandSentCarriesWhatThePlayerSentAndNotTheLogin() + { + var telnet = new FakeTelnetSession(); + var character = new CharacterDefinition { Name = "Ann", Password = "swordfish", OnConnect = "look" }; + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, character, sessionFactory: _ => telnet); + var sent = new List(); + session.CommandSent += (_, command) => sent.Add(command); + await session.ConnectAsync(); + + await session.SendUserInputAsync("n"); + + await Assert.That(sent).IsEquivalentTo(new[] { "n" }); + await Assert.That(telnet.SentLines).Contains("connect Ann swordfish"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/SessionMapTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/SessionMapTests.cs new file mode 100644 index 00000000..b9b746da --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/SessionMapTests.cs @@ -0,0 +1,61 @@ +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class SessionMapTests +{ + private static SessionMap Map(List sent) + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, Name = "Hall", Exits = { ["north"] = 2, ["east"] = 5 } }); + map.Add(new MapRoom { Id = 2, Name = "Square", Y = -1, Exits = { ["south"] = 1 } }); + map.Add(new MapRoom { Id = 5, Name = "Corridor", X = 1, Exits = { ["west"] = 1, ["east"] = 6 } }); + map.Add(new MapRoom { Id = 6, Name = "Corridor", X = 2, Exits = { ["west"] = 5, ["east"] = 7 } }); + map.Add(new MapRoom { Id = 7, Name = "Square", X = 3, Exits = { ["west"] = 6 } }); + var recorder = new MapRecorder(map); + recorder.Apply(new RoomObservation(1, "Hall", [new ObservedExit("north", 2), new ObservedExit("east", 5)])); + return new SessionMap(recorder, new Speedwalk(recorder, c => { sent.Add(c); return Task.CompletedTask; })); + } + + [Test] + public async Task ANameFindsTheNearestRoomCalledThat() + { + using var map = Map(new List()); + + await Assert.That(map.Find("square")!.Id).IsEqualTo(2L); + await Assert.That(map.Find("7")!.Id).IsEqualTo(7L); + await Assert.That(map.Find("Nowhere")).IsNull(); + } + + [Test] + public async Task WalkToStartsTheWalkOrSaysWhy() + { + var sent = new List(); + using var map = Map(sent); + + await Assert.That(map.WalkTo("Nowhere")).IsEqualTo("no room on the map is called 'Nowhere'"); + await Assert.That(map.WalkTo("7")).IsNull(); + await Assert.That(sent).IsEquivalentTo(new[] { "e" }); + await Assert.That(map.Walk.Destination).IsEqualTo(7L); + } + + [Test] + public async Task ALockedRoomIsWalkedAround() + { + using var map = Map(new List()); + map.SetLocked(5, true); + + await Assert.That(map.Map.Room(5)!.Locked).IsTrue(); + await Assert.That(map.WalkTo("7")).IsEqualTo("the map knows no way there"); + await Assert.That(map.Recorder.IsDirty).IsTrue(); + } + + [Test] + public async Task CurrentNamesTheRoomAndArea() + { + using var map = Map(new List()); + map.Map.Room(1)!.AreaId = map.Map.AreaNamed("Keep").Id; + + await Assert.That(map.Current).IsEqualTo((1L, "Hall", (string?)"Keep")); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Mapping/SpeedwalkTests.cs b/tests/SharpMUTerm.Core.Tests/Mapping/SpeedwalkTests.cs new file mode 100644 index 00000000..4e792b96 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Mapping/SpeedwalkTests.cs @@ -0,0 +1,112 @@ +using SharpMUTerm.Core.Mapping; + +namespace SharpMUTerm.Core.Tests.Mapping; + +public class SpeedwalkTests +{ + /// What a game reports on arriving: the room and the exits it already has, so the map is left as it is. + private static void Arrive(MapRecorder recorder, long id) => + recorder.Apply(new RoomObservation( + id, + recorder.Map.Room(id)?.Name ?? "Somewhere", + (recorder.Map.Room(id)?.Exits ?? new Dictionary()).Select(e => new ObservedExit(e.Key, e.Value)).ToList())); + + /// Hall (1) — north — Gallery (2) — east — Tower (3). + private static (MapRecorder Recorder, List Sent, Speedwalk Walk, ManualTimeProvider Clock) Walker( + string? failure = null) + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, Name = "Hall", Exits = { ["north"] = 2 } }); + map.Add(new MapRoom { Id = 2, Name = "Gallery", Y = -1, Exits = { ["south"] = 1, ["east"] = 3 } }); + map.Add(new MapRoom { Id = 3, Name = "Tower", X = 1, Y = -1, Exits = { ["west"] = 2 } }); + var recorder = new MapRecorder(map); + Arrive(recorder, 1); + var sent = new List(); + var clock = new ManualTimeProvider(); + var walk = new Speedwalk(recorder, c => { sent.Add(c); return Task.CompletedTask; }, failure, clock); + return (recorder, sent, walk, clock); + } + + [Test] + public async Task EachStepWaitsForTheRoomBefore() + { + var (recorder, sent, walk, _) = Walker(); + string? last = "unset"; + walk.Changed += (_, reason) => last = reason; + + walk.Start(PathFinder.Find(recorder.Map, 1, 3)!); + await Assert.That(sent).IsEquivalentTo(new[] { "n" }); + + Arrive(recorder, 2); + await Assert.That(sent).IsEquivalentTo(new[] { "n", "e" }); + await Assert.That(walk.Remaining).IsEqualTo(1); + + Arrive(recorder, 3); + await Assert.That(walk.IsWalking).IsFalse(); + await Assert.That(last).IsEqualTo("arrived"); + } + + [Test] + public async Task ALookInTheSameRoomDoesNotStopIt() + { + var (recorder, sent, walk, _) = Walker(); + walk.Start(PathFinder.Find(recorder.Map, 1, 3)!); + + Arrive(recorder, 1); + + await Assert.That(walk.IsWalking).IsTrue(); + await Assert.That(sent.Count).IsEqualTo(1); + } + + [Test] + public async Task ArrivingOffThePathStops() + { + var (recorder, _, walk, _) = Walker(); + recorder.Map.Add(new MapRoom { Id = 9, Name = "Pit" }); + walk.Start(PathFinder.Find(recorder.Map, 1, 3)!); + + Arrive(recorder, 9); + + await Assert.That(walk.IsWalking).IsFalse(); + } + + [Test] + public async Task TheFailurePatternStopsAtOnce() + { + var (recorder, _, walk, _) = Walker("can't go that way"); + string? last = null; + walk.Changed += (_, reason) => last = reason; + walk.Start(PathFinder.Find(recorder.Map, 1, 3)!); + + walk.Observe("You can't go that way."); + + await Assert.That(walk.IsWalking).IsFalse(); + await Assert.That(last).IsEqualTo("stopped: You can't go that way."); + } + + [Test] + public async Task AStepThatNeverArrivesTimesOut() + { + var (recorder, _, walk, clock) = Walker(); + walk.Start(PathFinder.Find(recorder.Map, 1, 3)!); + + clock.Advance(Speedwalk.StepTimeout - TimeSpan.FromSeconds(1)); + await Assert.That(walk.IsWalking).IsTrue(); + + clock.Advance(TimeSpan.FromSeconds(2)); + await Assert.That(walk.IsWalking).IsFalse(); + } + + [Test] + public async Task AnEmptyPathIsAlreadyThere() + { + var (_, sent, walk, _) = Walker(); + string? last = null; + walk.Changed += (_, reason) => last = reason; + + walk.Start(Array.Empty()); + + await Assert.That(sent).IsEmpty(); + await Assert.That(last).IsEqualTo("already there"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/FakeAudio.cs b/tests/SharpMUTerm.Core.Tests/Media/FakeAudio.cs new file mode 100644 index 00000000..72b23553 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/FakeAudio.cs @@ -0,0 +1,56 @@ +using SharpMUTerm.Core.Media; + +namespace SharpMUTerm.Core.Tests.Media; + +/// An audio device that records what it was asked to do. +internal sealed class FakeAudio : IAudioOutput +{ + public List Voices { get; } = new(); + + public IEnumerable Playing => Voices.Where(v => !v.Stopped).Select(v => Path.GetFileName(v.Path)); + + public IAudioVoice? Play(string path, AudioStart start) + { + var voice = new FakeVoice(path, start); + Voices.Add(voice); + return voice; + } + + public void Dispose() + { + } +} + +internal sealed class FakeVoice(string path, AudioStart start) : IAudioVoice +{ + public string Path { get; } = path; + + public AudioStart Start { get; } = start; + + public float Volume { get; private set; } = start.Volume; + + public (float To, TimeSpan Over)? Fade { get; private set; } + + public bool Paused { get; private set; } + + public bool Stopped { get; private set; } + + public event EventHandler? Ended; + + public void SetVolume(float volume) => Volume = volume; + + public void FadeTo(float volume, TimeSpan over) => Fade = (volume, over); + + public void Pause() => Paused = true; + + public void Resume() => Paused = false; + + /// The file played to its end. + public void Finish() + { + Stopped = true; + Ended?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() => Stopped = true; +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/ManualTimeProvider.cs b/tests/SharpMUTerm.Core.Tests/Media/ManualTimeProvider.cs new file mode 100644 index 00000000..afa858d5 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/ManualTimeProvider.cs @@ -0,0 +1,82 @@ +namespace SharpMUTerm.Core.Tests.Media; + +/// +/// A clock and timer source a test moves by hand. A copy of the Tui suite's, so the speedwalk timeout can be asserted +/// instead of waited on: a limiter tested with real time is a test that sleeps for its interval and +/// still races the machine it runs on. moves the clock and fires every timer +/// whose due time it passes, on the calling thread, so a trailing flush lands before the next line +/// of the test. +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly List _timers = new(); + private DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state); + _timers.Add(timer); + timer.Change(dueTime, period); + return timer; + } + + /// Moves the clock on by , firing every timer that comes due. + public void Advance(TimeSpan by) + { + _now += by; + + // A firing callback may re-arm its own timer (the flush does, when a session is still inside + // its interval), so fire from a snapshot and let the re-arm wait for the next Advance. + foreach (var timer in _timers.ToArray()) + { + timer.FireIfDue(_now); + } + } + + private void Forget(ManualTimer timer) => _timers.Remove(timer); + + private sealed class ManualTimer : ITimer + { + private readonly ManualTimeProvider _owner; + private readonly TimerCallback _callback; + private readonly object? _state; + private DateTimeOffset? _dueAt; + + public ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) + { + _owner = owner; + _callback = callback; + _state = state; + } + + /// Arms (or, with an infinite due time, disarms) the timer. Periods are unused here. + public bool Change(TimeSpan dueTime, TimeSpan period) + { + _dueAt = dueTime == Timeout.InfiniteTimeSpan ? null : _owner.GetUtcNow() + dueTime; + return true; + } + + public void FireIfDue(DateTimeOffset now) + { + if (_dueAt is { } due && now >= due) + { + _dueAt = null; + _callback(_state); + } + } + + public void Dispose() + { + _dueAt = null; + _owner.Forget(this); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/MediaAdvertTests.cs b/tests/SharpMUTerm.Core.Tests/Media/MediaAdvertTests.cs new file mode 100644 index 00000000..13f4f262 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/MediaAdvertTests.cs @@ -0,0 +1,20 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Media; + +public class MediaAdvertTests +{ + [Test] + public async Task ClientMediaIsAskedForOnlyWhenTheClientPlaysIt() + { + var world = new WorldDefinition { GmcpModules = { "IRE.Rift 1" } }; + + await Assert.That(WorldSession.GmcpModulesFor(world, playsMedia: true)) + .IsEquivalentTo(new[] { "IRE.Rift 1", MediaCommand.GmcpModule }); + await Assert.That(WorldSession.GmcpModulesFor(world, playsMedia: false)) + .IsEquivalentTo(new[] { "IRE.Rift 1" }); + await Assert.That(world.GmcpModules.Count).IsEqualTo(1); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/MediaCacheTests.cs b/tests/SharpMUTerm.Core.Tests/Media/MediaCacheTests.cs new file mode 100644 index 00000000..d3f1da89 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/MediaCacheTests.cs @@ -0,0 +1,114 @@ +using SharpMUTerm.Core.Media; + +namespace SharpMUTerm.Core.Tests.Media; + +public class MediaCacheTests +{ + [Test] + [Arguments("../secrets.wav")] + [Arguments("/etc/passwd.wav")] + [Arguments("C:/x.wav")] + [Arguments("a\\b.wav")] + [Arguments("sounds//x.wav")] + [Arguments("run.exe")] + [Arguments("")] + public async Task AServersNameMustBeARelativeSoundFile(string name) + { + await Assert.That(MediaCache.CheckName(name)).IsNotNull(); + } + + [Test] + [Arguments("rain.ogg")] + [Arguments("weather/Thunder.WAV")] + public async Task OrdinaryNamesAreAccepted(string name) + { + await Assert.That(MediaCache.CheckName(name)).IsNull(); + } + + [Test] + public async Task OnlyHttpAndHttpsFoldersAreFetchedFrom() + { + await Assert.That(MediaCache.UrlFor("a b.ogg", "https://x.example/s")!.AbsoluteUri).IsEqualTo("https://x.example/s/a%20b.ogg"); + await Assert.That(MediaCache.UrlFor("a.ogg", "file:///etc/")).IsNull(); + await Assert.That(MediaCache.UrlFor("a.ogg", null)).IsNull(); + } + + [Test] + public async Task ACacheWithNoRootFetchesNothing() + { + var fetched = false; + var cache = new MediaCache(fetch: (_, _, _) => { fetched = true; return Task.FromResult([1]); }); + + var (path, problem) = await cache.ResolveAsync("w", "rain.ogg", "https://x.example/"); + + await Assert.That(path).IsNull(); + await Assert.That(problem).IsNotNull(); + await Assert.That(fetched).IsFalse(); + } + + [Test] + public async Task AFileIsFetchedOnceAndThenReadFromDisk() + { + var root = Directory.CreateTempSubdirectory("smt-cache-").FullName; + var fetches = 0; + var cache = new MediaCache(root, (_, _, _) => { fetches++; return Task.FromResult([1, 2, 3]); }); + + var first = await cache.ResolveAsync("game.example:4000", "rain.ogg", "https://x.example/"); + var second = await cache.ResolveAsync("game.example:4000", "rain.ogg", "https://x.example/"); + + await Assert.That(first.Path).IsEqualTo(second.Path); + await Assert.That(fetches).IsEqualTo(1); + await Assert.That(first.Path!.StartsWith(root, StringComparison.Ordinal)).IsTrue(); + await Assert.That(Path.GetFileName(first.Path)).DoesNotContain("rain"); + } + + [Test] + public async Task DownloadingOffMeansOnlyWhatIsAlreadyHere() + { + var root = Directory.CreateTempSubdirectory("smt-cache-").FullName; + var cache = new MediaCache(root, (_, _, _) => Task.FromResult([1])); + + var (path, problem) = await cache.ResolveAsync("w", "rain.ogg", "https://x.example/", download: false); + + await Assert.That(path).IsNull(); + await Assert.That(problem).IsEqualTo("downloading is off for this world"); + } + + [Test] + public async Task AWorldsCacheIsCappedOldestFirst() + { + var root = Directory.CreateTempSubdirectory("smt-cache-").FullName; + var cache = new MediaCache(root, (_, _, _) => Task.FromResult(new byte[60])) { MaxWorldBytes = 100 }; + + var a = await cache.ResolveAsync("w", "a.ogg", "https://x.example/"); + File.SetLastWriteTimeUtc(a.Path!, DateTime.UtcNow.AddMinutes(-5)); + var b = await cache.ResolveAsync("w", "b.ogg", "https://x.example/"); + + await Assert.That(File.Exists(a.Path!)).IsFalse(); + await Assert.That(File.Exists(b.Path!)).IsTrue(); + } + + [Test] + public async Task AnOversizedFileIsRefused() + { + var root = Directory.CreateTempSubdirectory("smt-cache-").FullName; + var cache = new MediaCache(root, (_, _, _) => Task.FromResult(new byte[11])) { MaxFileBytes = 10 }; + + var (path, _) = await cache.ResolveAsync("w", "a.ogg", "https://x.example/"); + + await Assert.That(path).IsNull(); + await Assert.That(Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)).IsEmpty(); + } + + [Test] + public async Task ASoundpackCannotBeEscapedByName() + { + var pack = Directory.CreateTempSubdirectory("smt-pack-").FullName; + File.WriteAllBytes(Path.Combine(pack, "rain.ogg"), [1]); + var cache = new MediaCache(); + cache.LocalFolders.Add(pack); + + await Assert.That((await cache.ResolveAsync("w", "rain.ogg", null)).Path).IsEqualTo(Path.Combine(pack, "rain.ogg")); + await Assert.That((await cache.ResolveAsync("w", "../rain.ogg", null)).Path).IsNull(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/MediaCommandTests.cs b/tests/SharpMUTerm.Core.Tests/Media/MediaCommandTests.cs new file mode 100644 index 00000000..cb12247a --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/MediaCommandTests.cs @@ -0,0 +1,66 @@ +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Tests.Session; + +namespace SharpMUTerm.Core.Tests.Media; + +public class MediaCommandTests +{ + [Test] + public async Task APlayTakesTheSpecificationsDefaults() + { + var play = (MediaCommand.Play)MediaCommand.FromGmcp("Client.Media.Play", """{"name":"rain.ogg"}""")!; + + await Assert.That(play.Request.Volume).IsEqualTo(50); + await Assert.That(play.Request.Loops).IsEqualTo(1); + await Assert.That(play.Request.Kind).IsEqualTo(MediaKind.Sound); + await Assert.That(play.Request.Continue).IsTrue(); + await Assert.That(play.Request.Priority).IsNull(); + } + + [Test] + public async Task APlayReadsEveryField() + { + var play = (MediaCommand.Play)MediaCommand.FromGmcp( + "Client.Media.Play", + """{"name":"m/theme.mp3","type":"music","volume":200,"loops":-1,"priority":70,"continue":false,"key":"bgm","tag":"area","fadein":500,"caption":"a lute"}""")!; + + await Assert.That(play.Request.Kind).IsEqualTo(MediaKind.Music); + await Assert.That(play.Request.Volume).IsEqualTo(100); + await Assert.That(play.Request.Loops).IsEqualTo(-1); + await Assert.That(play.Request.Priority).IsEqualTo(70); + await Assert.That(play.Request.Continue).IsFalse(); + await Assert.That(play.Request.FadeIn).IsEqualTo(TimeSpan.FromMilliseconds(500)); + await Assert.That(play.Request.CaptionText).IsEqualTo("a lute"); + } + + [Test] + public async Task VideoAndOtherPackagesAreNotMedia() + { + await Assert.That(MediaCommand.FromGmcp("Client.Media.Play", """{"name":"x.mp4","type":"video"}""")).IsNull(); + await Assert.That(MediaCommand.FromGmcp("Char.Vitals", "{}")).IsNull(); + } + + [Test] + public async Task AnEmptyStopStopsEverything() + { + var stop = (MediaCommand.Stop)MediaCommand.FromGmcp("Client.Media.Stop", "")!; + + await Assert.That(stop.Filter).IsEqualTo(MediaFilter.All); + } + + [Test] + public async Task TheSessionRaisesMediaFromGmcp() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => telnet); + MediaCommand? received = null; + session.MediaReceived += (_, m) => received = m; + await session.ConnectAsync(); + + telnet.EmitGmcp("Client.Media.Default", """{"url":"https://x.example/s/"}"""); + + await Assert.That(received).IsEqualTo(new MediaCommand.SetDefault("https://x.example/s/")); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/MediaQueueTests.cs b/tests/SharpMUTerm.Core.Tests/Media/MediaQueueTests.cs new file mode 100644 index 00000000..2d7825ad --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/MediaQueueTests.cs @@ -0,0 +1,172 @@ +using SharpMUTerm.Core.Media; + +namespace SharpMUTerm.Core.Tests.Media; + +public class MediaQueueTests +{ + /// A queue whose cache resolves every name from a soundpack folder that holds it. + private static (MediaQueue Queue, FakeAudio Audio, ManualTimeProvider Clock, string Folder) Queue() + { + var folder = Directory.CreateTempSubdirectory("smt-media-").FullName; + foreach (var name in new[] { "rain.ogg", "thunder.wav", "theme.mp3", "battle.mp3", "bell.wav", "door.wav" }) + { + File.WriteAllBytes(Path.Combine(folder, name), [1]); + } + + var cache = new MediaCache(); + cache.LocalFolders.Add(folder); + var audio = new FakeAudio(); + var clock = new ManualTimeProvider(); + return (new MediaQueue(audio, cache, "w", clock), audio, clock, folder); + } + + [Test] + public async Task VolumeIsTheProductOfThePlayersLevelsAndTheRequests() + { + var (queue, audio, _, _) = Queue(); + queue.Mix = new MediaMix(Master: 50, Sound: 80); + + queue.Play(new MediaRequest("rain.ogg") { Volume = 50 }); + + await Assert.That(audio.Voices.Single().Start.Volume).IsEqualTo(0.5f * 0.8f * 0.5f).Within(0.0001f); + + queue.Mix = new MediaMix(Muted: true); + await Assert.That(audio.Voices.Single().Volume).IsEqualTo(0f); + } + + [Test] + public async Task HigherPriorityHaltsLowerAndRefusesItWhilePlaying() + { + var (queue, audio, _, _) = Queue(); + queue.Play(new MediaRequest("rain.ogg") { Priority = 10 }); + queue.Play(new MediaRequest("thunder.wav") { Priority = 60 }); + + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "thunder.wav" }); + + queue.Play(new MediaRequest("bell.wav") { Priority = 20 }); + queue.Play(new MediaRequest("door.wav")); + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "thunder.wav" }); + + queue.Play(new MediaRequest("rain.ogg") { Priority = 60 }); // a tie plays alongside + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "thunder.wav", "rain.ogg" }); + } + + [Test] + public async Task AnExclusiveSoundReplacesOnlyAtAHigherPriority() + { + var (queue, audio, _, _) = Queue(); + queue.Play(new MediaRequest("rain.ogg") { Exclusive = true, Priority = 50 }); + queue.Play(new MediaRequest("bell.wav") { Exclusive = true, Priority = 50 }); + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "rain.ogg" }); + + queue.Play(new MediaRequest("thunder.wav") { Exclusive = true, Priority = 51 }); + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "thunder.wav" }); + } + + [Test] + public async Task ADifferentFileUnderTheSameKeyReplacesTheOneBefore() + { + var (queue, audio, _, _) = Queue(); + queue.Play(new MediaRequest("theme.mp3") { Kind = MediaKind.Music, Key = "bgm" }); + queue.Play(new MediaRequest("rain.ogg")); + queue.Play(new MediaRequest("battle.mp3") { Kind = MediaKind.Music, Key = "bgm" }); + + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "rain.ogg", "battle.mp3" }); + } + + [Test] + public async Task MusicContinuesUnlessToldToRestart() + { + var (queue, audio, _, _) = Queue(); + var theme = new MediaRequest("theme.mp3") { Kind = MediaKind.Music }; + queue.Play(theme); + queue.Play(theme); + await Assert.That(audio.Voices.Count).IsEqualTo(1); + + queue.Play(theme with { Continue = false }); + await Assert.That(audio.Voices.Count).IsEqualTo(2); + await Assert.That(audio.Voices[0].Stopped).IsTrue(); + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "theme.mp3" }); + } + + [Test] + public async Task StopMatchesEverySetFieldAndAnEmptyStopEndsAll() + { + var (queue, audio, _, _) = Queue(); + queue.Play(new MediaRequest("rain.ogg") { Tag = "weather" }); + queue.Play(new MediaRequest("thunder.wav") { Tag = "weather" }); + queue.Play(new MediaRequest("theme.mp3") { Kind = MediaKind.Music, Tag = "weather" }); + + queue.Stop(new MediaFilter { Tag = "weather", Kind = MediaKind.Sound }); + await Assert.That(audio.Playing).IsEquivalentTo(new[] { "theme.mp3" }); + + queue.Stop(MediaFilter.All); + await Assert.That(audio.Playing).IsEmpty(); + await Assert.That(queue.Current).IsEmpty(); + } + + [Test] + public async Task AFadeAwayFadesThenStopsOnTheClock() + { + var (queue, audio, clock, _) = Queue(); + queue.Play(new MediaRequest("rain.ogg")); + + queue.Stop(MediaFilter.All, fadeAway: true, fadeOut: TimeSpan.FromSeconds(2)); + await Assert.That(audio.Voices[0].Fade).IsEqualTo((0f, TimeSpan.FromSeconds(2))); + await Assert.That(audio.Voices[0].Stopped).IsFalse(); + + clock.Advance(TimeSpan.FromSeconds(2)); + await Assert.That(audio.Voices[0].Stopped).IsTrue(); + } + + [Test] + public async Task APausedFileIsResumedByAMatchingPlay() + { + var (queue, audio, _, _) = Queue(); + queue.Play(new MediaRequest("theme.mp3") { Kind = MediaKind.Music, Continue = false }); + queue.Apply(new MediaCommand.Pause(new MediaFilter { Kind = MediaKind.Music })); + await Assert.That(audio.Voices[0].Paused).IsTrue(); + + queue.Play(new MediaRequest("theme.mp3") { Kind = MediaKind.Music, Continue = false }); + await Assert.That(audio.Voices.Count).IsEqualTo(1); + await Assert.That(audio.Voices[0].Paused).IsFalse(); + } + + [Test] + public async Task AFileThatEndsLeavesTheQueue() + { + var (queue, audio, _, _) = Queue(); + MediaRequest? started = null; + queue.Started += (_, r) => started = r; + queue.Play(new MediaRequest("rain.ogg") { Caption = "rain on the roof" }); + + audio.Voices[0].Finish(); + + await Assert.That(started!.CaptionText).IsEqualTo("rain on the roof"); + await Assert.That(queue.Current).IsEmpty(); + } + + [Test] + public async Task AStopWhileFetchingMeansItNeverStarts() + { + var gate = new TaskCompletionSource(); + var root = Directory.CreateTempSubdirectory("smt-cache-").FullName; + var cache = new MediaCache(root, (_, _, _) => gate.Task); + var audio = new FakeAudio(); + var queue = new MediaQueue(audio, cache, "w"); + queue.Apply(new MediaCommand.SetDefault("https://game.example/sounds/")); + + queue.Play(new MediaRequest("rain.ogg")); + queue.Stop(MediaFilter.All); + gate.SetResult([1, 2, 3]); + await Task.Delay(50); + + await Assert.That(audio.Voices).IsEmpty(); + } + + [Test] + public async Task ACaptionDefaultsToTheFileName() + { + await Assert.That(new MediaRequest("weather/thunder-near.wav").CaptionText).IsEqualTo("thunder-near"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Media/MspTriggerTests.cs b/tests/SharpMUTerm.Core.Tests/Media/MspTriggerTests.cs new file mode 100644 index 00000000..952e5979 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Media/MspTriggerTests.cs @@ -0,0 +1,91 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Tests.Session; + +namespace SharpMUTerm.Core.Tests.Media; + +public class MspTriggerTests +{ + private static MediaRequest Played(string line) => + ((MediaCommand.Play)MspTrigger.Parse(line)!).Request; + + [Test] + public async Task ASoundWithEveryField() + { + var sound = Played("!!SOUND(weather/rain.wav V=80 L=2 P=70 T=weather U=https://example.org/sounds/)"); + + await Assert.That(sound.Name).IsEqualTo("weather/rain.wav"); + await Assert.That(sound.Kind).IsEqualTo(MediaKind.Sound); + await Assert.That(sound.Volume).IsEqualTo(80); + await Assert.That(sound.Loops).IsEqualTo(2); + await Assert.That(sound.Priority).IsEqualTo(70); + await Assert.That(sound.Tag).IsEqualTo("weather"); + await Assert.That(sound.Url).IsEqualTo("https://example.org/sounds/"); + await Assert.That(sound.Exclusive).IsTrue(); + } + + [Test] + public async Task MspsDefaultsAreFilledIn() + { + var sound = Played("!!SOUND(bell.wav)"); + var music = Played("!!MUSIC(town.mid L=-1)"); + + await Assert.That(sound.Volume).IsEqualTo(100); + await Assert.That(sound.Loops).IsEqualTo(1); + await Assert.That(sound.Priority).IsEqualTo(50); + await Assert.That(music.Kind).IsEqualTo(MediaKind.Music); + await Assert.That(music.Loops).IsEqualTo(-1); + await Assert.That(music.Continue).IsTrue(); + await Assert.That(music.Key).IsEqualTo(MspTrigger.MusicKey); + await Assert.That(Played("!!MUSIC(town.mid C=0)").Continue).IsFalse(); + } + + [Test] + public async Task OffStopsThatKindAndCanSetTheFolder() + { + var stopSound = (MediaCommand.Stop)MspTrigger.Parse("!!SOUND(Off)")!; + var stopMusic = (MediaCommand.Stop)MspTrigger.Parse("!!MUSIC(Off)")!; + var folder = (MediaCommand.SetDefault)MspTrigger.Parse("!!SOUND(Off U=https://example.org/s/)")!; + + await Assert.That(stopSound.Filter.Kind).IsEqualTo(MediaKind.Sound); + await Assert.That(stopMusic.Filter.Kind).IsEqualTo(MediaKind.Music); + await Assert.That(folder.Url).IsEqualTo("https://example.org/s/"); + } + + [Test] + [Arguments("You hear !!SOUND(bell.wav)")] + [Arguments("!!SOUND(bell.wav) rings")] + [Arguments("!!SOUND()")] + [Arguments("!!SOUND(bell.wav V)")] + [Arguments("!!SOUND(bell.wav")] + [Arguments("!!NOISE(bell.wav)")] + public async Task AnythingButAWholeTriggerIsText(string line) + { + await Assert.That(MspTrigger.Parse(line)).IsNull(); + } + + [Test] + public async Task ATriggerIsTextUntilMspIsAgreed() + { + var telnet = new FakeTelnetSession(); + await using var session = new WorldSession( + new WorldDefinition { Name = "W", Host = "w.example", Port = 1 }, sessionFactory: _ => telnet); + var heard = new List(); + session.MediaReceived += (_, c) => heard.Add(c); + await session.ConnectAsync(); + + telnet.EmitLine("!!SOUND(bell.wav)"); + telnet.RaiseMsp(true); + telnet.EmitLine("!!SOUND(gong.wav)"); + telnet.RaiseMsp(false); + telnet.EmitLine("!!SOUND(drum.wav)"); + + var lines = session.Scrollback.Snapshot().Select(l => l.Text).ToList(); + await Assert.That(lines).Contains("!!SOUND(bell.wav)"); + await Assert.That(lines).DoesNotContain("!!SOUND(gong.wav)"); + await Assert.That(lines).Contains("!!SOUND(drum.wav)"); + await Assert.That(heard.Count).IsEqualTo(1); + await Assert.That(((MediaCommand.Play)heard[0]).Request.Name).IsEqualTo("gong.wav"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Packages/PackageIndexTests.cs b/tests/SharpMUTerm.Core.Tests/Packages/PackageIndexTests.cs new file mode 100644 index 00000000..89028112 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Packages/PackageIndexTests.cs @@ -0,0 +1,75 @@ +using System.Security.Cryptography; +using SharpMUTerm.Core.Packages; + +namespace SharpMUTerm.Core.Tests.Packages; + +public class PackageIndexTests +{ + private static string Hash(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); + + private static string Entry(string id, string games, string url = "https://p.example/x.zip", string? sha = null, string version = "1.0.0") => + $$"""{"id":"{{id}}","name":"{{id}}","version":"{{version}}","games":[{{games}}],"url":"{{url}}","sha256":"{{sha ?? new string('a', 64)}}"}"""; + + [Test] + public async Task AnIndexReadsItsEntries() + { + var (index, problem) = PackageIndex.Parse($$"""{"packages":[{{Entry("a.one", "\"aetherfall.mux:4201\"")}}]}"""); + + await Assert.That(problem).IsNull(); + await Assert.That(index!.Entries.Single().Id).IsEqualTo("a.one"); + await Assert.That(index.Dropped).IsEqualTo(0); + } + + [Test] + [Arguments("http://p.example/x.zip", null)] + [Arguments("file:///etc/passwd", null)] + [Arguments("https://p.example/x.zip", "abc")] + [Arguments("https://p.example/x.zip", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")] + public async Task AnEntryThatBreaksARuleIsDroppedAndCounted(string url, string? sha) + { + var (index, _) = PackageIndex.Parse($$"""{"packages":[{{Entry("a.one", "\"*\"", url, sha)}},{{Entry("b.two", "\"*\"")}}]}"""); + + await Assert.That(index!.Entries.Select(e => e.Id)).IsEquivalentTo(new[] { "b.two" }); + await Assert.That(index.Dropped).IsEqualTo(1); + } + + [Test] + public async Task ABadManifestOrARepeatedIdIsDropped() + { + var (index, _) = PackageIndex.Parse($$"""{"packages":[{{Entry("../x", "\"*\"")}},{{Entry("b.two", "\"*\"")}},{{Entry("b.two", "\"*\"")}},{{Entry("c", "")}},42]}"""); + + await Assert.That(index!.Entries.Select(e => e.Id)).IsEquivalentTo(new[] { "b.two" }); + await Assert.That(index.Dropped).IsEqualTo(4); + } + + [Test] + public async Task NotAnIndexIsSaidSo() + { + await Assert.That(PackageIndex.Parse("not json").Problem).IsNotNull(); + await Assert.That(PackageIndex.Parse("{}").Problem).IsNotNull(); + } + + [Test] + public async Task TheWorldsOwnPackagesComeFirstAndAnEverywherePackageDoesNotCount() + { + var (index, _) = PackageIndex.Parse($$""" + {"packages":[{{Entry("all", "\"*\"")}},{{Entry("other", "\"other.example\"")}},{{Entry("mine", "\"aetherfall.mux:4201\"")}}]} + """); + + await Assert.That(index!.For("aetherfall.mux", 4201).Select(e => e.Id)).IsEquivalentTo(new[] { "mine", "all", "other" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(index.For(null, 0).Select(e => e.Id)).IsEquivalentTo(new[] { "all", "mine", "other" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task VerifyChecksTheHashThenTheIdAndVersion() + { + var archive = PackageTests.Basics(); + var (index, _) = PackageIndex.Parse($$"""{"packages":[{{Entry("aether.basics", "\"*\"", sha: Hash(archive))}}]}"""); + var entry = index!.Entries.Single(); + var inside = PackageArchive.Read(archive).Contents!.Manifest; + + await Assert.That(entry.Verify(archive, inside)).IsNull(); + await Assert.That(entry.Verify([.. archive, 0], null)).Contains("SHA-256"); + await Assert.That((entry with { Version = "2.0.0" }).Verify(archive, inside)).Contains("not aether.basics 2.0.0"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Packages/PackageTests.cs b/tests/SharpMUTerm.Core.Tests/Packages/PackageTests.cs new file mode 100644 index 00000000..9632c5ec --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Packages/PackageTests.cs @@ -0,0 +1,231 @@ +using System.IO.Compression; +using System.Text; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Packages; + +namespace SharpMUTerm.Core.Tests.Packages; + +public class PackageTests +{ + internal const string Manifest = """ + {"id":"aether.basics","name":"Aether basics","version":"1.0.0","games":["aetherfall.example:4000"]} + """; + + internal const string Set = """ + {"name":"Basics","triggers":[{"name":"hp","pattern":"^HP low","actions":{"sendResponse":"quaff"}}], + "aliases":[{"name":"k","pattern":"^k (.+)$","substitution":"kill $1"}],"scriptFiles":["scripts/basics.lua"]} + """; + + internal static byte[] Zip(params (string Name, byte[] Bytes)[] entries) + { + using var buffer = new MemoryStream(); + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, bytes) in entries) + { + using var stream = zip.CreateEntry(name).Open(); + stream.Write(bytes); + } + } + + return buffer.ToArray(); + } + + internal static (string, byte[]) Text(string name, string text) => (name, Encoding.UTF8.GetBytes(text)); + + internal static byte[] Basics(string version = "1.0.0", string set = Set) => Zip( + Text("package.json", Manifest.Replace("1.0.0", version)), + Text("sets/basics.json", set), + Text("scripts/basics.lua", "function hello() end"), + ("media/bell.wav", [1, 2, 3]), + Text("README.md", "hi")); + + private static string TempRoot() => Directory.CreateTempSubdirectory("sharpmuterm-pkg-").FullName; + + [Test] + public async Task AnArchiveReadsIntoItsParts() + { + var (contents, problem) = PackageArchive.Read(Basics()); + + await Assert.That(problem).IsNull(); + await Assert.That(contents!.Manifest.Id).IsEqualTo("aether.basics"); + await Assert.That(contents.Sets.Single().Name).IsEqualTo("Basics"); + await Assert.That(contents.Scripts.Keys).IsEquivalentTo(new[] { "basics.lua" }); + await Assert.That(contents.Media.Keys).IsEquivalentTo(new[] { "bell.wav" }); + await Assert.That(contents.Ignored).IsEquivalentTo(new[] { "README.md" }); + + var review = contents.Review(); + await Assert.That(review.Triggers).IsEqualTo(1); + await Assert.That(review.Aliases).IsEqualTo(1); + await Assert.That(review.Sending).IsEqualTo(2); + } + + [Test] + [Arguments("../evil.lua")] + [Arguments("scripts/../../evil.lua")] + [Arguments("/etc/evil.lua")] + [Arguments("scripts\\evil.lua")] + [Arguments("C:/evil.lua")] + [Arguments("scripts/./evil.lua")] + [Arguments("sets/deep/evil.json")] + public async Task AnEntryOutsideTheFoldersIsNeverRead(string name) + { + var (contents, _) = PackageArchive.Read(Zip(Text("package.json", Manifest), Text(name, "x"))); + + await Assert.That(contents!.Scripts).IsEmpty(); + await Assert.That(contents.Sets).IsEmpty(); + await Assert.That(contents.Ignored).Contains(name); + } + + [Test] + public async Task AFileLargerThanItsCapIsRefusedWhileDecompressing() + { + var bomb = new byte[(int)PackageArchive.MaxTextBytes + 1]; + var archive = Zip(Text("package.json", Manifest), ("scripts/bomb.lua", bomb)); + + await Assert.That(archive.Length).IsLessThan(100_000); // zeros compress: the header's sizes are not trusted + var (contents, problem) = PackageArchive.Read(archive); + + await Assert.That(contents).IsNull(); + await Assert.That(problem).Contains("larger than"); + } + + [Test] + [Arguments("""{"id":"../x","name":"X","version":"1.0.0","games":["*"]}""", "id")] + [Arguments("""{"id":"x","name":"X","version":"one","games":["*"]}""", "version")] + [Arguments("""{"id":"x","name":"X","version":"1.0.0","games":[]}""", "games")] + [Arguments("""{"id":"x","name":"X","version":"1.0.0","games":["a b"]}""", "game address")] + [Arguments("""not json""", "parse")] + public async Task ABadManifestStopsEverything(string manifest, string says) + { + var (contents, problem) = PackageArchive.Read(Zip(Text("package.json", manifest), Text("sets/a.json", Set))); + + await Assert.That(contents).IsNull(); + await Assert.That(problem).Contains(says); + } + + [Test] + public async Task NoManifestIsNoPackage() + { + var (_, problem) = PackageArchive.Read(Zip(Text("sets/a.json", Set))); + + await Assert.That(problem).Contains("package.json"); + } + + [Test] + public async Task AnInstallSurvivesARestartAndItsScriptsResolveInsideIt() + { + var root = TempRoot(); + try + { + var store = new PackageStore(root); + await Assert.That(store.Install(PackageArchive.Read(Basics()).Contents!)).IsNull(); + + var again = new PackageStore(root); + again.Load(); + var installed = again.Find("aether.basics")!; + + await Assert.That(installed.Sets.Single().ScriptFiles).IsEquivalentTo(new[] { "package:aether.basics/basics.lua" }); + await Assert.That(again.ResolveScript("package:aether.basics/basics.lua")) + .IsEqualTo(Path.Combine(root, "aether.basics", "scripts", "basics.lua")); + await Assert.That(again.ResolveScript("package:aether.basics/../../config.json")).IsNull(); + await Assert.That(again.ResolveScript("package:other/basics.lua")).IsNull(); + await Assert.That(again.MediaFolders.Single()).IsEqualTo(Path.Combine(root, "aether.basics", "media")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task AnUpdateReplacesThePackagesSetsWholesale() + { + var root = TempRoot(); + try + { + var store = new PackageStore(root); + store.Install(PackageArchive.Read(Basics()).Contents!); + store.Install(PackageArchive.Read(Basics("1.1.0", """{"name":"Renamed"}""")).Contents!); + store.Load(); + + await Assert.That(store.Find("aether.basics")!.Manifest.Version).IsEqualTo("1.1.0"); + await Assert.That(store.Find("aether.basics")!.Sets.Select(s => s.Name)).IsEquivalentTo(new[] { "Renamed" }); + await Assert.That(Directory.GetDirectories(root).Select(d => Path.GetFileName(d)!)).IsEquivalentTo(new[] { "aether.basics" }); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task RemovingDeletesTheFolder() + { + var root = TempRoot(); + try + { + var store = new PackageStore(root); + store.Install(PackageArchive.Read(Basics()).Contents!); + + await Assert.That(store.Remove("aether.basics")).IsNull(); + await Assert.That(Directory.Exists(Path.Combine(root, "aether.basics"))).IsFalse(); + await Assert.That(store.Installed).IsEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task AStoreWithNoRootWritesNothing() + { + var store = new PackageStore(); + store.Install(PackageArchive.Read(Basics()).Contents!); + + await Assert.That(store.Installed.Count).IsEqualTo(1); + await Assert.That(store.ResolveScript("package:aether.basics/basics.lua")).IsNull(); + await Assert.That(store.MediaFolders).IsEmpty(); + } + + [Test] + public async Task APackagesSetsApplyOnlyToItsGamesAndOnlyWhileOn() + { + var store = new PackageStore(); + store.Install(PackageArchive.Read(Basics()).Contents!); + var config = new AppConfiguration { Packages = store.Installed }; + var ann = new CharacterDefinition { Name = "Ann" }; + var aether = new WorldDefinition { Host = "Aetherfall.example", Port = 4000 }; + var other = new WorldDefinition { Host = "aetherfall.example", Port = 4001 }; + + await Assert.That(config.ResolveTriggerSets(ann, aether).Select(s => s.Name)).IsEquivalentTo(new[] { "Basics" }); + await Assert.That(config.ResolveTriggerSets(ann, other)).IsEmpty(); + await Assert.That(config.ResolveTriggerSets(ann)).IsEmpty(); + + config.DisabledPackages.Add("aether.basics"); + await Assert.That(config.ResolveTriggerSets(ann, aether)).IsEmpty(); + } + + [Test] + [Arguments("*", "any.host", 1, true)] + [Arguments("*.example.org", "mud.example.org", 23, true)] + [Arguments("*.example.org", "example.org.evil", 23, false)] + [Arguments("mud.example.org", "MUD.example.org", 4000, true)] + [Arguments("mud.example.org:23", "mud.example.org", 4000, false)] + public async Task GamePatterns(string game, string host, int port, bool matches) + { + await Assert.That(PackageManifest.Matches(game, host, port)).IsEqualTo(matches); + } + + [Test] + public async Task PackageSetsAreNeverSaved() + { + var store = new PackageStore(); + store.Install(PackageArchive.Read(Basics()).Contents!); + var json = ConfigurationStore.Serialize(new AppConfiguration { Packages = store.Installed }); + + await Assert.That(json).DoesNotContain("Basics"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Presence/GamePresenceTests.cs b/tests/SharpMUTerm.Core.Tests/Presence/GamePresenceTests.cs new file mode 100644 index 00000000..18a15969 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Presence/GamePresenceTests.cs @@ -0,0 +1,168 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json.Nodes; +using SharpMUTerm.Core.Presence; + +namespace SharpMUTerm.Core.Tests.Presence; + +/// Reading External.Discord, and what goes down Discord's IPC socket. +public class GamePresenceTests +{ + [Test] + public async Task StatusBeforeInfoIsIgnoredAndInfoNamesTheApplication() + { + var p = GamePresence.Apply(null, GamePresence.StatusPackage, """{"details":"x"}"""); + await Assert.That(p).IsNull(); + + p = GamePresence.Apply(null, GamePresence.InfoPackage, """{"applicationid":"42","inviteurl":"https://x"}"""); + p = GamePresence.Apply(p, GamePresence.StatusPackage, """{"details":"Line\none","state":"Lvl 3","starttime":1700000000,"partysize":"2"}"""); + + await Assert.That(p!.ApplicationId).IsEqualTo("42"); + await Assert.That(p.Details).IsEqualTo("Line one"); + await Assert.That(p.State).IsEqualTo("Lvl 3"); + await Assert.That(p.StartTime!.Value.ToUnixTimeSeconds()).IsEqualTo(1700000000); + await Assert.That(p.PartySize).IsEqualTo(2); + } + + [Test] + public async Task ABadApplicationIdAndAnOverlongFieldAreRefusedOrCut() + { + await Assert.That(GamePresence.Apply(null, GamePresence.InfoPackage, """{"applicationid":"../../x"}""")).IsNull(); + var p = GamePresence.Apply(new GamePresence("1"), GamePresence.StatusPackage, "{\"details\":\"" + new string('d', 500) + "\"}"); + await Assert.That(p!.Details!.Length).IsEqualTo(GamePresence.MaxField); + await Assert.That(GamePresence.Apply(p, GamePresence.StatusPackage, "not json")).IsSameReferenceAs(p); + } + + [Test] + public async Task TheIpcHandshakesForTheGamesApplicationThenSetsTheActivity() + { + var stream = new FakeDiscord(); + using (var ipc = new DiscordIpc(_ => Task.FromResult(stream), pid: 7)) + { + ipc.Show(new GamePresence("42", Details: "Exploring", PartySize: 2, PartyMax: 4)); + await stream.WaitForFrames(2); + ipc.Show(null); + await stream.WaitForFrames(3); + } + + var frames = stream.Frames; + await Assert.That(frames[0].Op).IsEqualTo(0); + await Assert.That(frames[0].Body["client_id"]!.GetValue()).IsEqualTo("42"); + await Assert.That(frames[1].Body["cmd"]!.GetValue()).IsEqualTo("SET_ACTIVITY"); + await Assert.That(frames[1].Body["args"]!["pid"]!.GetValue()).IsEqualTo(7); + await Assert.That(frames[1].Body["args"]!["activity"]!["details"]!.GetValue()).IsEqualTo("Exploring"); + await Assert.That(frames[1].Body["args"]!["activity"]!["party"]!["size"]!.ToJsonString()).IsEqualTo("[2,4]"); + await Assert.That(frames[2].Body["args"]!["activity"]).IsNull(); + } + + [Test] + public async Task NoDiscordMeansNothingHappens() + { + var calls = 0; + using var ipc = new DiscordIpc(_ => { calls++; return Task.FromResult(null); }); + ipc.Show(new GamePresence("42")); + for (var i = 0; i < 100 && calls == 0; i++) + { + await Task.Delay(10); + } + + await Assert.That(calls).IsEqualTo(1); + } + + /// Discord's side of the socket: records each frame and answers it with an empty one. + private sealed class FakeDiscord : Stream + { + private readonly List _written = []; + private readonly Queue _replies = new(); + private readonly SemaphoreSlim _readable = new(0); + private readonly object _gate = new(); + + public List<(int Op, JsonObject Body)> Frames { get; } = []; + + public async Task WaitForFrames(int count) + { + for (var i = 0; i < 300; i++) + { + lock (_gate) + { + if (Frames.Count >= count) + { + return; + } + } + + await Task.Delay(10); + } + } + + public override void Write(byte[] buffer, int offset, int count) + { + lock (_gate) + { + _written.AddRange(buffer.AsSpan(offset, count).ToArray()); + while (_written.Count >= 8) + { + var header = _written.Take(8).ToArray(); + var length = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(4)); + if (_written.Count < 8 + length) + { + break; + } + + var body = Encoding.UTF8.GetString(_written.Skip(8).Take(length).ToArray()); + _written.RemoveRange(0, 8 + length); + Frames.Add((BinaryPrimitives.ReadInt32LittleEndian(header), (JsonObject)JsonNode.Parse(body)!)); + byte[] reply = [1, 0, 0, 0, 2, 0, 0, 0, (byte)'{', (byte)'}']; + foreach (var b in reply) + { + _replies.Enqueue(b); + } + + _readable.Release(); + } + } + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + while (true) + { + lock (_gate) + { + if (_replies.Count > 0) + { + var n = 0; + while (n < buffer.Length && _replies.Count > 0) + { + buffer.Span[n++] = _replies.Dequeue(); + } + + return n; + } + } + + await _readable.WaitAsync(cancellationToken); + } + } + + public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs new file mode 100644 index 00000000..9b383555 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs @@ -0,0 +1,138 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Tests.Session; + +namespace SharpMUTerm.Core.Tests.Protocols; + +/// +/// The session's out-of-band game data. Before it the client kept nine fixed Char.* keys, drew none +/// of them, and dropped MSDP on the floor. +/// +public class GameDataStoreTests +{ + [Test] + public async Task APathReadsAFieldOfTheLongestMatchingPackage() + { + var store = new GameDataStore(); + store.SetGmcp("Char", """{"name":"Ann"}"""); + store.SetGmcp("Char.Vitals", """{"hp":"450","maxhp":"500"}"""); + + await Assert.That(store.GetText("Char.Vitals.hp")).IsEqualTo("450"); + await Assert.That(store.GetNumber("Char.Vitals.maxhp")).IsEqualTo(500); + await Assert.That(store.GetText("Char.name")).IsEqualTo("Ann"); + await Assert.That(store.GetText("Char.Vitals.mp")).IsNull(); + } + + /// IRE spells it Char.Vitals, Aardwolf char.vitals; one meter reads both. + [Test] + public async Task NamesAndFieldsAreCaseInsensitive() + { + var store = new GameDataStore(); + store.SetGmcp("char.vitals", """{"HP":12}"""); + + await Assert.That(store.GetNumber("Char.Vitals.hp")).IsEqualTo(12); + } + + /// Char.Status sends only what changed; replacing the package would forget the rest. + [Test] + public async Task AnObjectIsMergedIntoWhatIsHeld() + { + var store = new GameDataStore(); + store.SetGmcp("Char.Status", """{"level":"10","race":"Elf"}"""); + store.SetGmcp("Char.Status", """{"level":"11"}"""); + + await Assert.That(store.GetText("Char.Status.level")).IsEqualTo("11"); + await Assert.That(store.GetText("Char.Status.race")).IsEqualTo("Elf"); + } + + [Test] + public async Task ArraysAreIndexedAndScalarsReplace() + { + var store = new GameDataStore(); + store.SetGmcp("Room.Players", """[{"name":"Bob"},{"name":"Cy"}]"""); + store.SetGmcp("Char.Count", "3"); + store.SetGmcp("Char.Count", "4"); + + await Assert.That(store.GetText("Room.Players.1.name")).IsEqualTo("Cy"); + await Assert.That(store.GetText("Char.Count")).IsEqualTo("4"); + } + + [Test] + public async Task MsdpVariablesAreHeldUnderTheirOwnNames() + { + var store = new GameDataStore(); + store.SetMsdp("""{"HEALTH":"90","HEALTH_MAX":"100"}"""); + + await Assert.That(store.GetNumber("MSDP.HEALTH")).IsEqualTo(90); + await Assert.That(store.GetNumber("msdp.health_max")).IsEqualTo(100); + await Assert.That(store.Count).IsEqualTo(2); + } + + [Test] + public async Task APayloadOverTheCapIsRefused() + { + var store = new GameDataStore(); + var big = "\"" + new string('x', GameDataStore.MaxPayloadBytes) + "\""; + + await Assert.That(store.SetGmcp("Big", big)).IsFalse(); + await Assert.That(store.Count).IsEqualTo(0); + } + + [Test] + public async Task NewPackagesStopAtTheCapAndHeldOnesStillUpdate() + { + var store = new GameDataStore(); + for (var i = 0; i < GameDataStore.MaxPackages; i++) + { + store.SetGmcp($"P{i}", "1"); + } + + await Assert.That(store.SetGmcp("OneTooMany", "1")).IsFalse(); + await Assert.That(store.SetGmcp("P0", "2")).IsTrue(); + await Assert.That(store.GetText("P0")).IsEqualTo("2"); + } + + [Test] + public async Task MalformedJsonIsRefusedAndAnEmptyBodyIsHeld() + { + var store = new GameDataStore(); + + await Assert.That(store.SetGmcp("Bad", "{nope")).IsFalse(); + await Assert.That(store.SetGmcp("Core.Ping", "")).IsTrue(); + await Assert.That(store.Entries.Select(e => e.Package)).IsEquivalentTo(new[] { "Core.Ping" }); + } + + [Test] + public async Task TheSessionStoresWhatArrivesAndClearsOnConnect() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.EmitGmcp("Char.Vitals", """{"hp":5}"""); + telnet.EmitMsdp("""{"MANA":"7"}"""); + await Assert.That(session.GameData.GetNumber("Char.Vitals.hp")).IsEqualTo(5); + await Assert.That(session.GameData.GetNumber("MSDP.MANA")).IsEqualTo(7); + + await session.DisconnectAsync(); + await session.ConnectAsync(); + await Assert.That(session.GameData.Count).IsEqualTo(0); + } + + /// An MSDP server sends nothing until asked; the meters' variables are asked for once it is agreed. + [Test] + public async Task AgreeingMsdpReportsTheVariablesTheSessionWants() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => telnet) + { + MsdpReports = ["HEALTH", "HEALTH_MAX", "health"], + }; + await session.ConnectAsync(); + + telnet.RaiseMsdpEnabled(); + + await Assert.That(telnet.SentMsdp).IsEquivalentTo(new[] { ("REPORT", "HEALTH"), ("REPORT", "HEALTH_MAX") }); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs new file mode 100644 index 00000000..90761910 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs @@ -0,0 +1,462 @@ +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Protocols; + +/// +/// What a server defines and declares over MXP — elements, entities, VAR, GAUGE, SOUND, FRAME and DEST — each fed +/// the way a session feeds it: one line, then a flush. +/// +public class MxpDefinitionTests +{ + private const string Secure = "\x1b[1z"; + + private static List Lines(MxpParser parser, params string[] lines) + { + var result = new List(); + foreach (var text in lines) + { + parser.Feed(text); + result.Add(parser.Flush()); + } + + return result; + } + + // ----- Reading a tag ------------------------------------------------------------ + + [Test] + public async Task AnUnclosedTagEndsWithItsLine() + { + var parser = new MxpParser(); + var lines = Lines(parser, "a < b and x", "y"); + + await Assert.That(lines[0]!.Text).IsEqualTo("x"); + await Assert.That(lines[1]!.Text).IsEqualTo("y"); + } + + // ----- ---------------------------------------------------------- + + [Test] + public async Task AnElementOpensItsDefinitionAndItsCloserClosesIt() + { + var parser = new MxpParser(); + var lines = Lines(parser, Secure + "'>", Secure + "Danger ahead"); + + await Assert.That(lines[0]).IsNull(); + await Assert.That(parser.LineSwallowed).IsFalse(); // the second line is text + var danger = lines[1]!.Spans.First(s => s.Text == "Danger"); + await Assert.That(danger.Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); + await Assert.That(lines[1]!.Spans.First(s => s.Text.Contains("ahead")).Style.Attributes.HasFlag(TextAttributes.Bold)).IsFalse(); + } + + [Test] + public async Task ALineThatIsOnlyDefinitionsIsSwallowedNotBlank() + { + var parser = new MxpParser(); + + parser.Feed(Secure + "'>"); + await Assert.That(parser.Flush()).IsNull(); + await Assert.That(parser.LineSwallowed).IsTrue(); + + parser.Feed(string.Empty); + await Assert.That(parser.Flush()).IsNull(); + await Assert.That(parser.LineSwallowed).IsFalse(); + } + + [Test] + public async Task AttributesAreFilledInByNameByPositionOrByDefault() + { + var parser = new MxpParser(); + var lines = Lines( + parser, + Secure + "' ATT='dir tip=Walk'>", + Secure + "N S"); + + var spans = lines[1]!.Spans.Where(s => s.IsInteractive).ToArray(); + await Assert.That(spans[0].Interaction!.Target).IsEqualTo("go north"); + await Assert.That(spans[0].Interaction!.Hint).IsEqualTo("Walk"); + await Assert.That(spans[1].Interaction!.Target).IsEqualTo("go south"); + await Assert.That(spans[1].Interaction!.Hint).IsEqualTo("Run"); + } + + [Test] + public async Task ADefinitionOnAnOpenLineIsRefusedAndEchoed() + { + var parser = new MxpParser(); + var lines = Lines(parser, "'>", Secure + "x"); + + await Assert.That(lines[0]!.Text).IsEqualTo("'>"); + await Assert.That(parser.ElementCount).IsEqualTo(0); + await Assert.That(lines[1]!.Spans.Any(s => s.IsInteractive)).IsFalse(); + } + + [Test] + public async Task APlayerMayUseAnOpenElementButItCannotCarryASecureTag() + { + var parser = new MxpParser(); + var lines = Lines( + parser, + Secure + "' ATT='c' OPEN>", + "look", + "look"); + + var look = lines[1]!.Spans.Single(s => s.Text == "look"); + await Assert.That(look.Style.Foreground).IsNotEqualTo(TerminalColor.Default); + await Assert.That(look.IsInteractive).IsFalse(); + await Assert.That(lines[2]!.Spans.Any(s => s.IsInteractive)).IsFalse(); + await Assert.That(lines[2]!.Text).IsEqualTo("look"); + } + + [Test] + public async Task ANonOpenElementOnAnOpenLineIsEchoed() + { + var parser = new MxpParser(); + var lines = Lines(parser, Secure + "'>", "x"); + + await Assert.That(lines[1]!.Text).IsEqualTo("x"); + } + + [Test] + public async Task ABuiltInCannotBeRedefined() + { + var parser = new MxpParser(); + Lines(parser, Secure + "'>", Secure + "' OPEN>", Secure + "'>"); + + await Assert.That(parser.ElementCount).IsEqualTo(0); + } + + [Test] + public async Task ExpansionIsCappedInDepthAndPerLine() + { + var parser = new MxpParser(); + Lines( + parser, + Secure + "'>", + Secure + "'>", + Secure + "'>", + Secure + "'>", + Secure + "'>"); + + var line = Lines(parser, Secure + string.Concat(Enumerable.Repeat("", 50)) + "done")[0]; + + await Assert.That(line!.Text).IsEqualTo("done"); + } + + [Test] + public async Task ALongDefinitionAndTooManyElementsAreRefused() + { + var parser = new MxpParser(); + Lines(parser, Secure + $""); + await Assert.That(parser.ElementCount).IsEqualTo(0); + + Lines(parser, Enumerable.Range(0, MxpParser.MaxElements + 10).Select(i => Secure + $"'>").ToArray()); + await Assert.That(parser.ElementCount).IsEqualTo(MxpParser.MaxElements); + } + + [Test] + public async Task DeleteRemovesAnElement() + { + var parser = new MxpParser(); + Lines(parser, Secure + "'>", Secure + ""); + + await Assert.That(parser.ElementCount).IsEqualTo(0); + } + + [Test] + public async Task AReconnectStartsWithoutDefinitions() + { + var parser = new MxpParser(); + Lines(parser, Secure + "'>", Secure + ""); + + parser.Reset(); + + await Assert.That(parser.ElementCount).IsEqualTo(0); + await Assert.That(parser.Entity("hp")).IsNull(); + } + + // ----- Entities ------------------------------------------------------------- + + [Test] + public async Task AnEntityIsSetExpandedAddedToRemovedAndDeleted() + { + var parser = new MxpParser(); + var changes = new List(); + parser.EntityChanged += (_, e) => changes.Add(e); + + var lines = Lines( + parser, + Secure + "", + "You have &hp; hit points", + Secure + "", + Secure + ""); + + await Assert.That(lines[1]!.Text).IsEqualTo("You have 42 hit points"); + await Assert.That(changes.Select(c => (c.Name, c.Value))).IsEquivalentTo( + new (string, string?)[] { ("hp", "42"), ("list", "a"), ("list", "a|b"), ("list", "b"), ("hp", null) }, + TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task APrivateEntityExpandsButIsNotAnnounced() + { + var parser = new MxpParser(); + var changes = new List(); + parser.EntityChanged += (_, e) => changes.Add(e); + + var lines = Lines(parser, Secure + "", "&secret;"); + + await Assert.That(lines[1]!.Text).IsEqualTo("x"); + await Assert.That(changes).IsEmpty(); + } + + [Test] + public async Task ABuiltInEntityCannotBeRedefinedAndAnEntityIsTextNotMarkup() + { + var parser = new MxpParser(); + var lines = Lines(parser, Secure + "", Secure + "'>", Secure + "<&tag;"); + + await Assert.That(lines[2]!.Text).IsEqualTo("<"); + await Assert.That(lines[2]!.Spans.Any(s => s.IsInteractive)).IsFalse(); + } + + [Test] + public async Task VarShowsItsTextAndStoresIt() + { + var parser = new MxpParser(); + var lines = Lines(parser, Secure + "Gold: 120"); + + await Assert.That(lines[0]!.Text).IsEqualTo("Gold: 120"); + await Assert.That(parser.Entity("gold")).IsEqualTo("120"); + } + + [Test] + public async Task AnElementFlaggedSetStoresItsText() + { + var parser = new MxpParser(); + Lines(parser, Secure + "", Secure + "HP: 17"); + + await Assert.That(parser.Entity("hp")).IsEqualTo("17"); + } + + [Test] + public async Task VarOnAnOpenLineIsEchoed() + { + var parser = new MxpParser(); + var lines = Lines(parser, "1"); + + await Assert.That(lines[0]!.Text).IsEqualTo("1"); + await Assert.That(parser.Entity("gold")).IsNull(); + } + + // ----- Flags and the room source --------------------------------------------- + + [Test] + public async Task RoomFlagsReachTheRoomSource() + { + var parser = new MxpParser(); + var flags = new List(); + parser.Flagged += (_, e) => flags.Add(e); + + Lines( + parser, + Secure + "' FLAG=RoomName>", + Secure + "1234The Grand Plaza", + Secure + "Exits: north, east and down"); + + await Assert.That(flags.Select(f => f.Flag)).IsEquivalentTo( + new[] { "RoomName", "RoomExit" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + + RoomObservation? seen = null; + using var source = new MxpRoomSourceProbe(); + source.Source.RoomEntered += (_, o) => seen = o; + source.Source.Observe("RoomNum", "1234"); + source.Source.Observe("RoomName", "The Grand Plaza"); + source.Source.Observe("RoomExit", "Exits: north, east and down"); + + await Assert.That(seen!.Id).IsEqualTo(1234L); + await Assert.That(seen.Name).IsEqualTo("The Grand Plaza"); + await Assert.That(seen.Exits.Select(e => e.Key)).IsEquivalentTo( + new[] { "north", "east", "down" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task TheSpecsRoomLineTagsFlagTheirLine() + { + var parser = new MxpParser(); + var flags = new List(); + parser.Flagged += (_, e) => flags.Add(e); + + var lines = Lines(parser, "\x1b[10zThe Grand Plaza", "\x1b[12zExits: north"); + + await Assert.That(lines[0]!.Text).IsEqualTo("The Grand Plaza"); + await Assert.That(flags.Select(f => (f.Flag, f.Text))).IsEquivalentTo( + new[] { ("RoomName", "The Grand Plaza"), ("RoomExit", "Exits: north") }, + TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task AFlaggedElementSpanningLinesKeepsAllItsText() + { + var parser = new MxpParser(); + var flags = new List(); + parser.Flagged += (_, e) => flags.Add(e); + + Lines(parser, Secure + "", Secure + "A wide plaza.", Secure + "A fountain."); + + await Assert.That(flags.Single().Text).IsEqualTo("A wide plaza.\nA fountain."); + } + + [Test] + public async Task AUserLineTagOpensTheElementTiedToIt() + { + var parser = new MxpParser(); + var lines = Lines(parser, Secure + "' TAG=20>", "\x1b[20zHeading", "plain"); + + await Assert.That(lines[1]!.Spans.Single().Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); + await Assert.That(lines[2]!.Spans.Single().Style.Attributes.HasFlag(TextAttributes.Bold)).IsFalse(); + } + + // ----- GAUGE, SOUND --------------------------------------------------------------- + + [Test] + public async Task AGaugeIsDeclaredOnASecureLineOnly() + { + var parser = new MxpParser(); + var gauges = new List(); + parser.GaugeDeclared += (_, e) => gauges.Add(e); + + var lines = Lines(parser, Secure + "", ""); + + await Assert.That(gauges.Single()).IsEqualTo(new MxpGauge("hp", "maxhp", "Health", false)); + await Assert.That(lines[1]!.Text).IsEqualTo(""); + } + + [Test] + public async Task SoundAndMusicAreTheSameRequestsMspMakes() + { + var parser = new MxpParser(); + var media = new List(); + parser.MediaRequested += (_, e) => media.Add(e); + + Lines(parser, Secure + "", Secure + "", ""); + + var sound = (MediaCommand.Play)media[0]; + await Assert.That(sound.Request.Name).IsEqualTo("bell.wav"); + await Assert.That(sound.Request.Volume).IsEqualTo(40); + await Assert.That(sound.Request.Priority).IsEqualTo(80); + await Assert.That(sound.Request.Url).IsEqualTo("https://m.example/"); + var music = (MediaCommand.Play)media[1]; + await Assert.That(music.Request.Kind).IsEqualTo(MediaKind.Music); + await Assert.That(music.Request.Loops).IsEqualTo(-1); + await Assert.That(media.Count).IsEqualTo(2); + } + + // ----- FRAME and DEST --------------------------------------------------------------- + + [Test] + public async Task DestCutsItsTextOutOfTheLineAndSendsItToTheFrame() + { + var parser = new MxpParser(); + var sent = new List(); + parser.Redirected += (_, e) => sent.Add(e); + + var lines = Lines(parser, Secure + "Main text Rivane: hi more", Secure + "all of it"); + + await Assert.That(lines[0]!.Text).IsEqualTo("Main text more"); + await Assert.That(lines[1]).IsNull(); + await Assert.That(parser.LineSwallowed).IsTrue(); + await Assert.That(sent.Select(s => (s.Frame, s.Line.Text))).IsEquivalentTo( + new[] { ("Chat", "Rivane: hi"), ("Chat", "all of it") }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task ARedirectSendsWholeLinesUntilTop() + { + var parser = new MxpParser(); + var sent = new List(); + parser.Redirected += (_, e) => sent.Add(e); + + var lines = Lines(parser, Secure + "", "hp 10", "mp 5", Secure + "", "back"); + + await Assert.That(sent.Select(s => s.Line.Text)).IsEquivalentTo(new[] { "hp 10", "mp 5" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(lines[4]!.Text).IsEqualTo("back"); + } + + [Test] + public async Task OnlyAFewFramesMayBeNamedAndTheRestStayInTheMainWindow() + { + var parser = new MxpParser(); + var sent = new List(); + parser.Redirected += (_, e) => sent.Add(e); + + var lines = Lines(parser, Enumerable.Range(0, MxpParser.MaxFrames + 2).Select(i => Secure + $"t{i}").ToArray()); + + await Assert.That(sent.Count).IsEqualTo(MxpParser.MaxFrames); + await Assert.That(lines[^1]!.Text).IsEqualTo($"t{MxpParser.MaxFrames + 1}"); + } + + [Test] + public async Task DestOnAnOpenLineIsEchoed() + { + var parser = new MxpParser(); + var sent = new List(); + parser.Redirected += (_, e) => sent.Add(e); + + var lines = Lines(parser, "forged"); + + await Assert.That(sent).IsEmpty(); + await Assert.That(lines[0]!.Text).IsEqualTo("forged"); + } + + // ----- SUPPORTS ------------------------------------------------------------------------- + + [Test] + public async Task TheSupportsListNamesWhatIsHandled() + { + var parser = new MxpParser(); + string? reply = null; + parser.ClientReply += (_, r) => reply = r; + + Lines(parser, Secure + ""); + + foreach (var tag in new[] { "+!element", "+!entity", "+var", "+gauge", "+stat", "+sound", "+music", "+frame", "+dest" }) + { + await Assert.That(reply!).Contains(tag); + } + + await Assert.That(reply!).DoesNotContain("+high"); + } + + /// An MXP room source on a session nothing connects, so Observe can be driven directly. + private sealed class MxpRoomSourceProbe : IDisposable + { + public MxpRoomSourceProbe() + { + Session = new SharpMUTerm.Core.Session.WorldSession(new SharpMUTerm.Core.Configuration.WorldDefinition { Name = "T", Host = "h", Port = 1 }); + Source = new MxpRoomSource(Session); + } + + public SharpMUTerm.Core.Session.WorldSession Session { get; } + + public MxpRoomSource Source { get; } + + public void Dispose() + { + Source.Dispose(); + _ = Session.DisposeAsync(); + } + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs new file mode 100644 index 00000000..38ac5a92 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs @@ -0,0 +1,87 @@ +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Protocols; + +/// MXP <SEND> menus and &text;, fed a line and a flush the way a session does. +public class MxpSendMenuTests +{ + private const string Secure = "\x1b[1z"; + + private static StyledLine Line(string text) + { + var parser = new MxpParser(); + parser.Feed(text); + return parser.Flush() ?? StyledLine.Empty; + } + + private static SpanInteraction Clickable(StyledLine line) => line.Spans.Single(s => s.IsInteractive).Interaction!; + + [Test] + public async Task SeveralCommandsMakeAMenuWhoseFirstIsTheClick() + { + var send = Clickable(Line(Secure + "sword")); + + await Assert.That(send.Target).IsEqualTo("buy sword"); + await Assert.That(send.Choices!.Select(c => c.Command)).IsEquivalentTo( + new[] { "buy sword", "sell sword", "look sword" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(send.Choices!.Select(c => c.Label)).IsEquivalentTo( + new[] { "buy sword", "sell sword", "look sword" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task AHintWithOneMorePartIsTheTooltipThenTheLabels() + { + var send = Clickable(Line(Secure + "sword")); + + await Assert.That(send.Hint).IsEqualTo("The sword"); + await Assert.That(send.Choices!.Select(c => c.Label)).IsEquivalentTo( + new[] { "Buy", "Sell" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task AHintWithAsManyPartsIsTheLabelsAlone() + { + var send = Clickable(Line(Secure + "go")); + + await Assert.That(send.Hint).IsNull(); + await Assert.That(send.Choices!.Select(c => c.Label)).IsEquivalentTo( + new[] { "North", "South" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task OneCommandIsNoMenuAndKeepsItsHint() + { + var send = Clickable(Line(Secure + "here")); + + await Assert.That(send.Choices).IsNull(); + await Assert.That(send.Hint).IsEqualTo("Look|around"); + } + + [Test] + public async Task TextIsFilledInWithTheElementsOwnText() + { + var send = Clickable(Line(Secure + "a lantern")); + + await Assert.That(send.Target).IsEqualTo("look a lantern"); + await Assert.That(send.Choices!.Last().Command).IsEqualTo("get a lantern"); + } + + [Test] + public async Task AMenuIsCappedAtTwentyCommands() + { + var href = string.Join('|', Enumerable.Range(0, 30).Select(i => $"c{i}")); + var send = Clickable(Line(Secure + $"x")); + + await Assert.That(send.Choices!.Count).IsEqualTo(MxpParser.MaxSendChoices); + } + + [Test] + public async Task OnAnOpenLineAMenuIsTextLikeAnyOtherSend() + { + var line = Line("x"); + + await Assert.That(line.Spans.Any(s => s.IsInteractive)).IsFalse(); + await Assert.That(line.Text).Contains(""); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs index 3e20d4c1..e64e2124 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs @@ -293,11 +293,53 @@ public async Task StrayLessThan_WithoutClose_IsEmittedLiterally() await Assert.That(line.Text).IsEqualTo("love <3"); } + /// + /// ANSI is decoded here, because nothing in front of this parser decodes it: a Pueblo world's colour used + /// to arrive as a literal <ESC>[33m. + /// [Test] - public async Task Esc_IsPassedThroughUntouched() + public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() { - var line = ParseSingleLine("a\x1bb\n"); - await Assert.That(line.Text).IsEqualTo("a\x1bb"); + var line = ParseSingleLine("\x1b[33mYellow\x1b[0m plain\n"); + + await Assert.That(line.Text).IsEqualTo("Yellow plain"); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); + await Assert.That(line.Spans[^1].Style.Foreground).IsEqualTo(TerminalColor.Default); + } + + [Test] + public async Task Ansi_AndPuebloMarkupComposeOnOneSpan() + { + var line = ParseSingleLine("\x1b[31mred bold\n"); + + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(1)); + await Assert.That(line.Spans[0].Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Ansi_OtherSequencesAreConsumed() + { + // Cursor movement, an OSC title (BEL- and ST-terminated), a charset select and a two-byte escape. + var line = ParseSingleLine("a\x1b[2Kb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); + + await Assert.That(line.Text).IsEqualTo("abcdef"); + } + + /// + /// An unterminated string escape a player typed ends with the line, in production's shape: fed without + /// its newline (the telnet layer strips it), then flushed. + /// + [Test] + public async Task Ansi_AnUnterminatedOscEndsWithTheLine() + { + var parser = new PuebloParser(); + parser.Feed("before\x1b]0;never closed"); + var first = parser.Flush(); + parser.Feed("next line"); + var second = parser.Flush(); + + await Assert.That(first!.Text).IsEqualTo("before"); + await Assert.That(second!.Text).IsEqualTo("next line"); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Session/EditGrabTests.cs b/tests/SharpMUTerm.Core.Tests/Session/EditGrabTests.cs new file mode 100644 index 00000000..74d53e14 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/EditGrabTests.cs @@ -0,0 +1,72 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Session; + +/// A MUSH's edit grab: the prefixed line is taken out, raised, and never shown, triggered on or sent. +public class EditGrabTests +{ + private static (WorldSession Session, FakeTelnetSession Telnet, List Grabs) Create(string? prefix, TriggerSet? set = null) + { + var telnet = new FakeTelnetSession(); + var world = new WorldDefinition { Name = "Ares", Host = "ares.example", Port = 4201, GrabPrefix = prefix }; + var session = new WorldSession(world, triggerSets: set is null ? null : new[] { set }, sessionFactory: _ => telnet); + var grabs = new List(); + session.EditGrabbed += (_, text) => grabs.Add(text); + return (session, telnet, grabs); + } + + [Test] + public async Task APrefixedLineIsGrabbedAndNotShown() + { + var (session, telnet, grabs) = Create("FugueEdit >"); + await session.ConnectAsync(); + + telnet.EmitLine("FugueEdit > &desc me=A tall figure in grey."); + telnet.EmitLine("You see nothing special."); + + await Assert.That(grabs).IsEquivalentTo(new[] { "&desc me=A tall figure in grey." }); + var shown = session.Scrollback.Snapshot().Select(l => l.Text).ToArray(); + await Assert.That(shown.Any(t => t.Contains("FugueEdit", StringComparison.Ordinal))).IsFalse(); + await Assert.That(shown).Contains("You see nothing special."); + } + + [Test] + public async Task NoPrefixMeansNoGrab() + { + var (session, telnet, grabs) = Create(null); + await session.ConnectAsync(); + + telnet.EmitLine("FugueEdit > &desc me=x"); + + await Assert.That(grabs).IsEmpty(); + await Assert.That(session.Scrollback.Snapshot().Select(l => l.Text)).Contains("FugueEdit > &desc me=x"); + } + + [Test] + public async Task AGrabIsNeitherTriggeredOnNorSent() + { + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = "desc", Actions = new TriggerActions { SendResponse = "say caught" } }); + var (session, telnet, grabs) = Create("FugueEdit >", set); + await session.ConnectAsync(); + + telnet.EmitLine("FugueEdit > &desc me=x"); + + await Assert.That(grabs).Count().IsEqualTo(1); + await Assert.That(telnet.SentLines).DoesNotContain("say caught"); + } + + [Test] + public async Task TheMatchIsExactAndAtTheStart() + { + var (session, telnet, grabs) = Create("FugueEdit >"); + await session.ConnectAsync(); + + telnet.EmitLine("fugueedit > lower case"); + telnet.EmitLine("Bob pages: FugueEdit > not at the start"); + + await Assert.That(grabs).IsEmpty(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs index 3afc5546..1740d4cb 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs @@ -16,13 +16,20 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? OutputReceived; public event EventHandler? GmcpReceived; -#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsdpReceived; +#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsspReceived; #pragma warning restore CS0067 public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; + public event EventHandler? MsdpEnabled; + public event EventHandler? MspChanged; + + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -39,8 +46,17 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) => - ValueTask.CompletedTask; + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + SentGmcp.Add((package, json)); + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + SentMsdp.Add((variable, value)); + return ValueTask.CompletedTask; + } public ValueTask SetWindowSizeAsync(int width, int height) => ValueTask.CompletedTask; @@ -73,4 +89,12 @@ public void EmitEncoding(SessionEncoding encoding) } public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + + public void RaiseMsp(bool active) => MspChanged?.Invoke(this, active); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); } diff --git a/tests/SharpMUTerm.Core.Tests/Session/GmcpLoginTests.cs b/tests/SharpMUTerm.Core.Tests/Session/GmcpLoginTests.cs new file mode 100644 index 00000000..734a5ad6 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/GmcpLoginTests.cs @@ -0,0 +1,105 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Session; + +/// GMCP Char.Login: opt-in per world, credentials only when the server offers them, the connect line otherwise. +public class GmcpLoginTests +{ + private static (WorldSession Session, FakeTelnetSession Telnet) Create(bool gmcpLogin, TimeSpan? wait = null) + { + var telnet = new FakeTelnetSession(); + var world = new WorldDefinition { Name = "Achaea", Host = "h", Port = 23, GmcpLogin = gmcpLogin }; + var character = new CharacterDefinition { Name = "Ann", Password = "s3cret", OnConnect = "look" }; + world.Characters.Add(character); + var session = new WorldSession(world, character, sessionFactory: _ => telnet); + if (wait is { } w) + { + session.GmcpLoginWait = w; + } + + return (session, telnet); + } + + private static string Text(WorldSession session) => + string.Join("\n", session.Scrollback.Snapshot().Select(l => l.Text)); + + [Test] + public async Task OffTheConnectLineGoesOutAtOnceAndNoModuleIsAskedFor() + { + var (session, telnet) = Create(gmcpLogin: false); + await session.ConnectAsync(); + + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "connect Ann s3cret", "look" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(WorldSession.GmcpModulesFor(session.World, playsMedia: false)).DoesNotContain(GmcpLogin.Module); + } + + [Test] + public async Task AServerOfferingCredentialsGetsThemOverGmcpAndNoConnectLine() + { + var (session, telnet) = Create(gmcpLogin: true); + await Assert.That(WorldSession.GmcpModulesFor(session.World, playsMedia: false)).Contains(GmcpLogin.Module); + await session.ConnectAsync(); + await Assert.That(telnet.SentLines).IsEmpty(); + + telnet.EmitGmcp("Char.Login.Default", """{"type":["password-credentials"]}"""); + await WaitFor(() => telnet.SentLines.Count > 0); + + await Assert.That(telnet.SentGmcp).Contains(("Char.Login.Credentials", """{"account":"Ann","password":"s3cret"}""")); + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "look" }); + await Assert.That(Text(session)).DoesNotContain("s3cret"); + + telnet.EmitGmcp("Char.Login.Result", """{"success":false,"message":"Bad\npassword."}"""); + await Assert.That(Text(session)).Contains("The GMCP login for Ann was refused: Bad password."); + } + + [Test] + public async Task AServerOfferingSomethingElseGetsTheConnectLine() + { + var (session, telnet) = Create(gmcpLogin: true); + await session.ConnectAsync(); + + telnet.EmitGmcp("Char.Login.Default", """{"type":"oauth"}"""); + await WaitFor(() => telnet.SentLines.Count >= 2); + + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "connect Ann s3cret", "look" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(telnet.SentGmcp.Any(m => m.Package == GmcpLogin.CredentialsPackage)).IsFalse(); + } + + [Test] + public async Task AServerThatSaysNothingGetsTheConnectLineAfterTheWaitAndOnlyOnce() + { + var (session, telnet) = Create(gmcpLogin: true, wait: TimeSpan.FromMilliseconds(50)); + await session.ConnectAsync(); + + await WaitFor(() => telnet.SentLines.Count >= 2); + telnet.EmitGmcp("Char.Login.Default", """{"type":["password-credentials"]}"""); + await Task.Delay(100); + + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "connect Ann s3cret", "look" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(telnet.SentGmcp.Any(m => m.Package == GmcpLogin.CredentialsPackage)).IsFalse(); + } + + [Test] + public async Task TheDefaultsTypeAndTheResultAreReadDefensively() + { + await Assert.That(GmcpLogin.OffersCredentials("""{"type":"password-credentials"}""")).IsTrue(); + await Assert.That(GmcpLogin.OffersCredentials("""{"type":[1,"PASSWORD-CREDENTIALS"]}""")).IsTrue(); + await Assert.That(GmcpLogin.OffersCredentials("not json")).IsFalse(); + await Assert.That(GmcpLogin.OffersCredentials("{}")).IsFalse(); + await Assert.That(GmcpLogin.ReadResult("""{"success":true}""")).IsEqualTo(new LoginResult(true, null)); + await Assert.That(GmcpLogin.ReadResult("""{"message":"x"}""")).IsNull(); + var body = System.Text.Json.Nodes.JsonNode.Parse(GmcpLogin.Credentials("A\"n", "p\\w"))!; + await Assert.That(body["account"]!.GetValue()).IsEqualTo("A\"n"); + await Assert.That(body["password"]!.GetValue()).IsEqualTo("p\\w"); + } + + private static async Task WaitFor(Func condition) + { + for (var i = 0; i < 200 && !condition(); i++) + { + await Task.Delay(10); + } + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/SendGroupTests.cs b/tests/SharpMUTerm.Core.Tests/Session/SendGroupTests.cs new file mode 100644 index 00000000..1cc7ac3b --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/SendGroupTests.cs @@ -0,0 +1,50 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Session; + +/// Who a group line and a script's world.sendTo reach. +public class SendGroupTests +{ + private static WorldSession Session(string world, string character) => + new(new WorldDefinition { Name = world, Host = "h", Port = 1 }, new CharacterDefinition { Name = character }, + sessionFactory: _ => new FakeTelnetSession()); + + [Test] + public async Task EachConnectedMemberOnceInGroupOrderAndTheRestNamed() + { + var ann = Session("Mush", "Ann"); + var bob = Session("Mush", "Bob"); + var cy = Session("Mush", "Cy"); + await ann.ConnectAsync(); + await bob.ConnectAsync(); + var group = new SendGroup(); + group.Toggle("Mush.Bob"); + group.Toggle("Mush.Cy"); + group.Toggle("Mush.Ann"); + group.Toggle("Mush.Gone"); + + var (reachable, missed) = group.Resolve([ann, bob, cy, bob]); + + await Assert.That(reachable).IsEquivalentTo(new[] { bob, ann }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(missed).IsEquivalentTo(new[] { "Cy", "Mush.Gone" }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(group.Toggle("Mush.Bob")).IsFalse(); + await Assert.That(group.Keys).DoesNotContain("Mush.Bob"); + } + + [Test] + public async Task AScriptNamesACharacterByKeyOrByANameOnlyOneOfThemHas() + { + var ann = Session("Mush", "Ann"); + var ann2 = Session("Moo", "Ann"); + var bob = Session("Mush", "Bob"); + await ann.ConnectAsync(); + await ann2.ConnectAsync(); + WorldSession[] open = [ann, ann2, bob]; + + await Assert.That(SendGroup.Find(open, "moo.ann").Session).IsSameReferenceAs(ann2); + await Assert.That(SendGroup.Find(open, "Ann").Refusal).Contains("2 open characters"); + await Assert.That(SendGroup.Find(open, "Bob").Refusal).Contains("not connected"); + await Assert.That(SendGroup.Find(open, "Zed").Refusal).Contains("no open character"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionScriptRequestTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionScriptRequestTests.cs new file mode 100644 index 00000000..8c3702df --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionScriptRequestTests.cs @@ -0,0 +1,129 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Session; + +/// +/// Every rule kind with a script field asks for it through one event, . +/// Before it, a trigger raised an event nothing subscribed to, and aliases, timers and macros raised nothing at +/// all: the F2 script field could be filled in and saved, and nothing ever ran. +/// +public class WorldSessionScriptRequestTests +{ + private static WorldDefinition World() => new() { Name = "T", Host = "h", Port = 1 }; + + private static (WorldSession Session, FakeTelnetSession Telnet, List Requests) Create(TriggerSet set) + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), triggerSets: new[] { set }, sessionFactory: _ => telnet); + var requests = new List(); + session.ScriptRequested += (_, e) => + { + lock (requests) + { + requests.Add(e); + } + }; + return (session, telnet, requests); + } + + [Test] + public async Task ATriggerAsksForItsScriptWithTheWholeMatchAndItsGroups() + { + var set = new TriggerSet { Name = "s" }; + set.Triggers.Add(new Trigger + { + Name = "tells", + Pattern = @"^(\w+) tells you '(.*)'$", + Actions = new TriggerActions { ScriptCallback = "on_tell" }, + }); + var (session, telnet, requests) = Create(set); + await session.ConnectAsync(); + + telnet.EmitLine("Ann tells you 'hi'"); + + await Assert.That(requests).HasSingleItem(); + var request = requests[0]; + await Assert.That(request.Source).IsEqualTo(ScriptSource.Trigger); + await Assert.That(request.Callback).IsEqualTo("on_tell"); + await Assert.That(request.WholeMatch).IsEqualTo("Ann tells you 'hi'"); + await Assert.That(request.Groups).IsEquivalentTo(new[] { "Ann", "hi" }); + } + + [Test] + public async Task AnAliasAsksForItsScriptAndStillSendsItsSubstitution() + { + var set = new TriggerSet { Name = "s" }; + set.Aliases.Add(new Alias { Pattern = @"^k (\w+)$", Substitution = "kill $1", ScriptCallback = "on_kill" }); + var (session, telnet, requests) = Create(set); + await session.ConnectAsync(); + + await session.SendUserInputAsync("k orc"); + + await Assert.That(requests).HasSingleItem(); + await Assert.That(requests[0].Source).IsEqualTo(ScriptSource.Alias); + await Assert.That(requests[0].Groups).IsEquivalentTo(new[] { "orc" }); + await Assert.That(telnet.SentLines).Contains("kill orc"); + } + + /// A script-only alias swallows the line: it matched, so the raw input is not sent as well. + [Test] + public async Task AScriptOnlyAliasSendsNothingItself() + { + var set = new TriggerSet { Name = "s" }; + set.Aliases.Add(new Alias { Pattern = "^go$", ScriptCallback = "walk" }); + var (session, telnet, requests) = Create(set); + await session.ConnectAsync(); + + await session.SendUserInputAsync("go"); + + await Assert.That(requests).HasSingleItem(); + await Assert.That(telnet.SentLines).IsEmpty(); + } + + [Test] + public async Task AMacroAsksForItsScriptAndSendsNoEmptyCommand() + { + var set = new TriggerSet { Name = "s" }; + set.Macros.Add(new Macro { Key = "F12", ScriptCallback = "panic" }); + var (session, telnet, requests) = Create(set); + await session.ConnectAsync(); + + var sent = await session.HandleKeyAsync("F12"); + + await Assert.That(sent).IsNull(); + await Assert.That(requests).HasSingleItem(); + await Assert.That(requests[0].Source).IsEqualTo(ScriptSource.Macro); + await Assert.That(requests[0].Callback).IsEqualTo("panic"); + await Assert.That(telnet.SentLines).IsEmpty(); + } + + [Test] + public async Task ATimerAsksForItsScriptEachTimeItFires() + { + var set = new TriggerSet { Name = "s" }; + set.Timers.Add(new TimerDefinition { Name = "tick", IntervalSeconds = 0.02, ScriptCallback = "on_tick" }); + var (session, telnet, requests) = Create(set); + await session.ConnectAsync(); + + var deadline = DateTime.UtcNow.AddSeconds(3); + while (DateTime.UtcNow < deadline && Count(requests) < 2) + { + await Task.Delay(10); + } + + await session.DisconnectAsync(); + await Assert.That(Count(requests)).IsGreaterThanOrEqualTo(2); + await Assert.That(requests[0].Source).IsEqualTo(ScriptSource.Timer); + await Assert.That(telnet.SentLines).IsEmpty(); + } + + private static int Count(List requests) + { + lock (requests) + { + return requests.Count; + } + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs new file mode 100644 index 00000000..9157a08c --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs @@ -0,0 +1,138 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Core.Tests.Session; + +/// +/// The world and character settings F5 gained: the on-disconnect commands, the ansi content choice, and +/// emoji switches that reach the next connection. +/// +public class WorldSessionSettingsTests +{ + private static WorldDefinition World() => new() { Name = "T", Host = "h", Port = 1 }; + + [Test] + public async Task DisconnectingSendsTheOnDisconnectCommandsFirst() + { + var telnet = new FakeTelnetSession(); + var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "say bye; QUIT" }; + var session = new WorldSession(World(), character, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + await session.DisconnectAsync(); + + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "say bye", "QUIT" }); + await Assert.That(session.IsConnected).IsFalse(); + } + + [Test] + public async Task QuittingTheClientSendsThemToo() + { + var telnet = new FakeTelnetSession(); + var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "QUIT" }; + var session = new WorldSession(World(), character, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + await session.DisposeAsync(); + + await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "QUIT" }); + } + + [Test] + public async Task AConnectionTheServerDroppedSendsNothing() + { + var telnet = new FakeTelnetSession(); + var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "QUIT" }; + var session = new WorldSession(World(), character, sessionFactory: _ => telnet); + await session.ConnectAsync(); + await telnet.DisconnectAsync(); // the server's close, as the session sees it + + await session.DisconnectAsync(); + await session.DisposeAsync(); + + await Assert.That(telnet.SentLines).IsEmpty(); + } + + [Test] + public async Task AnAnsiOnlyWorldIsNotSwitchedToMxp() + { + var world = World(); + world.ContentFormat = ContentFormat.AnsiOnly; + var telnet = new FakeTelnetSession(); + var session = new WorldSession(world, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaiseMxpEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsTrue(); + } + + [Test] + public async Task EmojiSwitchesChangedBetweenConnectionsApplyToTheNextOne() + { + var world = World(); + var telnet = new FakeTelnetSession(); + var session = new WorldSession(world, sessionFactory: _ => telnet); + await session.ConnectAsync(); + telnet.EmitLine("one :fire:"); + + await session.DisconnectAsync(); + world.Emoji.Enabled = true; + await session.ConnectAsync(); + telnet.EmitLine("two :fire:"); + + var lines = session.Scrollback.Snapshot(); + await Assert.That(lines.Any(l => l.Text == "one :fire:")).IsTrue(); + await Assert.That(lines.Any(l => l.Text.StartsWith("two", StringComparison.Ordinal) && l.Text.Contains("🔥", StringComparison.Ordinal))).IsTrue(); + } + [Test] + public async Task PuebloNegotiationSwitchesAnAutomaticWorldsParser() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.EmitLine("before"); + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + var lines = session.Scrollback.Snapshot(); + await Assert.That(lines.Any(l => l.Text == "before")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "after")).IsTrue(); + } + + /// + /// The first markup a connection negotiates keeps it: MXP after Pueblo leaves the Pueblo parser in place. + /// SEND tells them apart — a fresh MXP parser refuses it on an open line and echoes it, Pueblo + /// strips it. + /// + [Test] + public async Task MxpAfterPuebloDoesNotSwitchAgain() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.RaiseMxpEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsTrue(); + } + + [Test] + public async Task AnExplicitMxpWorldIgnoresPueblo() + { + var world = World(); + world.ContentFormat = ContentFormat.Mxp; + var telnet = new FakeTelnetSession(); + var session = new WorldSession(world, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsFalse(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs new file mode 100644 index 00000000..c873f8e1 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs @@ -0,0 +1,148 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// Once a server's GMCP is agreed the client introduces itself — Core.Hello, then +/// Core.Supports.Set — and not before. Servers in the IRE tradition send nothing until the second +/// arrives, and this client used to send neither. +/// +public class GmcpHandshakeTests +{ + private const byte Iac = 255; + private const byte Will = 251; + private const byte Sb = 250; + private const byte Se = 240; + private const byte Gmcp = 201; + private const byte Msdp = 69; + + [Test] + public async Task AnAgreedGmcpGetsHelloThenSupportsSet() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Gmcp] }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { GmcpModules = ["IRE.Rift 1"] }); + await session.ConnectAsync(); + + var messages = await Eventually(() => GmcpMessages(transport.Sent), m => m.Count >= 2); + + await Assert.That(messages.Count).IsEqualTo(2); + await Assert.That(messages[0]).StartsWith("Core.Hello "); + await Assert.That(messages[0]).Contains("\"SharpMUTerm\""); + await Assert.That(messages[1]).StartsWith("Core.Supports.Set "); + await Assert.That(messages[1]).Contains("\"Char 1\""); + await Assert.That(messages[1]).Contains("\"Room 1\""); + await Assert.That(messages[1]).Contains("\"IRE.Rift 1\""); + } + + /// The handshake is an answer to the server's offer, never a question of the client's own. + [Test] + public async Task NoOfferMeansNoHandshake() + { + var transport = new ScriptedTransport(); + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(GmcpMessages(transport.Sent)).IsEmpty(); + } + + [Test] + public async Task TheHandshakeCanBeSwitchedOff() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Gmcp] }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { IntroduceOverGmcp = false }); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(GmcpMessages(transport.Sent)).IsEmpty(); + } + + [Test] + public async Task AgreeingMsdpIsReported() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Msdp] }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var raised = 0; + session.MsdpEnabled += (_, _) => Interlocked.Increment(ref raised); + await session.ConnectAsync(); + + await Eventually(() => Volatile.Read(ref raised), n => n > 0); + + await Assert.That(Volatile.Read(ref raised)).IsEqualTo(1); + } + + [Test] + [Arguments("Char 1", "Char", 1)] + [Arguments("IRE.Rift", "IRE.Rift", 1)] + [Arguments(" Comm.Channel 2 ", "Comm.Channel", 2)] + public async Task AModuleParses(string spec, string name, int version) + { + var module = GmcpHandshake.Parse(spec); + + await Assert.That(module).IsNotNull(); + await Assert.That(module!.Value.Name).IsEqualTo(name); + await Assert.That(module.Value.Version).IsEqualTo(version); + } + + [Test] + [Arguments("")] + [Arguments("Char one")] + [Arguments("Char 0")] + [Arguments("Char 1 2")] + [Arguments(".Char 1")] + [Arguments("Char\"; 1")] + public async Task ANonsenseModuleIsDropped(string spec) => + await Assert.That(GmcpHandshake.Parse(spec)).IsNull(); + + /// A world's own entry for a default module replaces its version rather than listing it twice. + [Test] + public async Task AWorldsModuleOverridesADefaultsVersion() + { + var modules = GmcpHandshake.Modules(["room 2", "IRE.Rift 1"]); + + await Assert.That(modules.Count(m => m.Name.Equals("Room", StringComparison.OrdinalIgnoreCase))).IsEqualTo(1); + await Assert.That(modules.Single(m => m.Name.Equals("room", StringComparison.OrdinalIgnoreCase)).Version).IsEqualTo(2); + await Assert.That(modules[^1].Name).IsEqualTo("IRE.Rift"); + } + + /// Every IAC SB GMCP … IAC SE payload in a byte stream, as text. + private static List GmcpMessages(byte[] wire) + { + var messages = new List(); + for (var i = 0; i + 2 < wire.Length; i++) + { + if (wire[i] != Iac || wire[i + 1] != Sb || wire[i + 2] != Gmcp) + { + continue; + } + + var start = i + 3; + var end = start; + while (end + 1 < wire.Length && !(wire[end] == Iac && wire[end + 1] == Se)) + { + end++; + } + + messages.Add(Encoding.UTF8.GetString(wire, start, end - start)); + i = end; + } + + return messages; + } + + private static async Task Eventually(Func read, Func done) + { + var value = read(); + for (var i = 0; i < 100 && !done(value); i++) + { + await Task.Delay(20); + value = read(); + } + + return value; + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/McpEditTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/McpEditTests.cs new file mode 100644 index 00000000..d07c6d6f --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/McpEditTests.cs @@ -0,0 +1,190 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// MCP 2.1 from the client's side, against a scripted MOO: the server's offer is answered, simpleedit hands +/// a buffer over and takes it back, vmoo-client hears who this is, and a line a player typed is nobody's. +/// +public class McpEditTests +{ + private const string Offer = "#$#mcp version: \"2.1\" to: \"2.1\"\r\n"; + + private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text); + + private sealed class Moo : IAsyncDisposable + { + public ScriptedTransport Transport { get; } = new() { Greeting = Ascii("Welcome to the MOO.\r\n" + Offer) }; + + public TelnetSession Session { get; } + + public List Output { get; } = []; + + public List Edits { get; } = []; + + public string Key { get; private set; } = string.Empty; + + public Moo() + { + Session = new TelnetSession(Transport, NullLogger.Instance); + Session.OutputReceived += (_, e) => { lock (Output) Output.Add(e.Text); }; + Session.MooEditReceived += (_, e) => { lock (Edits) Edits.Add(e); }; + } + + public string Sent => Encoding.ASCII.GetString(Transport.Sent); + + /// Connects, reads the key the client chose, and agrees the packages a MOO with them would. + public Task OpenAsync(params string[] packages) => OpenAsync(() => Task.CompletedTask, packages); + + public async Task OpenAsync(Func beforeAgreeing, params string[] packages) + { + await Session.ConnectAsync(); + await beforeAgreeing(); + await Until(() => Regex.IsMatch(Sent, "#\\$#mcp-negotiate-end \\S+\r\n")); + Key = Regex.Match(Sent, "authentication-key: \"?([^\" ]+)\"?").Groups[1].Value; + var reply = new StringBuilder(); + foreach (var package in new[] { "mcp-negotiate" }.Concat(packages)) + { + var max = package == "mcp-negotiate" ? "2.0" : "1.0"; + reply.Append($"#$#mcp-negotiate-can {Key} package: \"{package}\" min-version: \"1.0\" max-version: \"{max}\"\r\n"); + } + + reply.Append($"#$#mcp-negotiate-end {Key}\r\n"); + Transport.SendToClient(Ascii(reply.ToString())); + } + + public void Send(string text) => Transport.SendToClient(Ascii(text)); + + public ValueTask DisposeAsync() => Session.DisposeAsync(); + } + + private static async Task Until(Func condition) + { + for (var i = 0; i < 200 && !condition(); i++) + { + await Task.Delay(10); + } + } + + [Test] + public async Task TheOfferIsAnsweredWithTheEditingPackages() + { + await using var moo = new Moo(); + await moo.OpenAsync(); + + await Assert.That(moo.Key).IsNotEmpty(); + await Assert.That(moo.Sent).Contains("package: \"dns-org-mud-moo-simpleedit\""); + await Assert.That(moo.Sent).Contains("package: \"dns-com-vmoo-client\""); + await Assert.That(moo.Sent).Contains("package: \"dns-com-awns-status\""); + await Task.Delay(50); + string[] seen; + lock (moo.Output) seen = [.. moo.Output]; + + // The offer is protocol, not text. + await Assert.That(seen.Any(l => l.Contains("#$#", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task ASilentServerHearsNoMcp() + { + var transport = new ScriptedTransport { Greeting = Ascii("Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("#$#"); + } + + [Test] + public async Task ContentArrivesAsAnEditAndTheSaveGoesBackUnderItsReference() + { + await using var moo = new Moo(); + await moo.OpenAsync("dns-org-mud-moo-simpleedit"); + moo.Send( + $"#$#dns-org-mud-moo-simpleedit-content {moo.Key} reference: \"#98.look\" name: \"Test:look\" type: \"moo-code\" content*: \"\" _data-tag: \"77\"\r\n" + + "#$#* 77 content: player:tell(\"Hi.\");\r\n" + + "#$#* 77 content: return 1;\r\n" + + "#$#: 77\r\n"); + await Until(() => { lock (moo.Edits) return moo.Edits.Count > 0; }); + + var edit = moo.Edits.Single(); + await Assert.That(edit).IsEqualTo(edit with { Reference = "#98.look", Name = "Test:look", Type = "moo-code" }); + await Assert.That(edit.Lines).IsEquivalentTo(new[] { "player:tell(\"Hi.\");", "return 1;" }); + + await moo.Session.SendMooEditAsync(edit, ["player:tell(\"Hello.\");", "return 1;"]); + await Until(() => moo.Sent.Contains("#$#: ", StringComparison.Ordinal)); + + var sent = moo.Sent; + await Assert.That(sent).Contains($"#$#dns-org-mud-moo-simpleedit-set {moo.Key} reference: \"#98.look\" type: \"moo-code\" content*: \"\""); + await Assert.That(sent).Contains("content: player:tell(\"Hello.\");\r\n"); + await Assert.That(sent).Contains("content: return 1;\r\n"); + } + + [Test] + public async Task AnEditTypedByAPlayerIsNeitherOpenedNorShown() + { + await using var moo = new Moo(); + await moo.OpenAsync("dns-org-mud-moo-simpleedit"); + moo.Send("#$#dns-org-mud-moo-simpleedit-content 0000 reference: \"#1.x\" name: \"x\" type: \"moo-code\" content*: \"\" _data-tag: \"9\"\r\n" + + "#$#* 9 content: @shutdown\r\n#$#: 9\r\nAfter.\r\n"); + await Until(() => { lock (moo.Output) return moo.Output.Contains("After."); }); + + await Assert.That(moo.Edits).IsEmpty(); + string[] seen; + lock (moo.Output) seen = [.. moo.Output]; + await Assert.That(seen.Any(l => l.Contains("shutdown", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task ThePeerHearsWhoThisIsAndHowWide() + { + await using var moo = new Moo(); + await moo.OpenAsync(async () => await moo.Session.SetWindowSizeAsync(100, 40), "dns-com-vmoo-client"); + await Until(() => moo.Sent.Contains("screensize", StringComparison.Ordinal)); + + await Assert.That(moo.Sent).Contains($"#$#dns-com-vmoo-client-info {moo.Key} name: \"SharpMUTerm\""); + await Assert.That(moo.Sent).Contains($"#$#dns-com-vmoo-client-screensize {moo.Key} cols: \"100\" rows: \"40\""); + + // A resize after agreement is told to the MOO as well as to NAWS. + await moo.Session.SetWindowSizeAsync(90, 30); + await Until(() => moo.Sent.Contains("cols: \"90\"", StringComparison.Ordinal)); + await Assert.That(moo.Sent).Contains($"#$#dns-com-vmoo-client-screensize {moo.Key} cols: \"90\" rows: \"30\""); + } + + [Test] + public async Task StatusTextIsRaised() + { + await using var moo = new Moo(); + var status = new List(); + moo.Session.McpStatusReceived += (_, text) => { lock (status) status.Add(text); }; + await moo.OpenAsync("dns-com-awns-status"); + moo.Send($"#$#dns-com-awns-status {moo.Key} text: \"You are hungry.\"\r\n"); + await Until(() => { lock (status) return status.Count > 0; }); + + await Assert.That(status.Single()).IsEqualTo("You are hungry."); + } + + [Test] + public async Task APromptAfterAConsumedLineIsOnlyThePrompt() + { + await using var moo = new Moo(); + await moo.OpenAsync("dns-com-awns-status"); + var prompts = new List(); + moo.Session.OutputReceived += (_, e) => { if (e.IsPrompt) lock (prompts) prompts.Add(e.Text); }; + moo.Transport.SendToClient([.. Ascii($"#$#dns-com-awns-status {moo.Key} text: \"x\"\r\n> "), 255, 249]); + await Until(() => { lock (prompts) return prompts.Count > 0; }); + + await Assert.That(prompts.Single()).IsEqualTo("> "); + } + + [Test] + public async Task LinesOfKeepsInteriorBlanksAndDropsOnlyTheCaretsNewline() + { + await Assert.That(MooEditing.LinesOf("a\r\n\r\nb\n")).IsEquivalentTo(new[] { "a", "", "b" }); + await Assert.That(MooEditing.LinesOf(" indented\n")).IsEquivalentTo(new[] { " indented" }); + await Assert.That(MooEditing.LinesOf(string.Empty)).IsEmpty(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MnesTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MnesTests.cs new file mode 100644 index 00000000..07acd959 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/MnesTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// NEW-ENVIRON as MNES: answered when a server asks, with the client's identity and nothing about the machine. +public class MnesTests +{ + private const byte Iac = 255, Sb = 250, Se = 240, Do = 253, Will = 251, NewEnviron = 39; + private const byte Is = 0, Send = 1, Var = 0, Val = 1; + + private static async Task Until(Func condition) + { + for (var i = 0; i < 200 && !condition(); i++) + { + await Task.Delay(10); + } + } + + private static bool Contains(byte[] haystack, byte[] needle) => + haystack.AsSpan().IndexOf(needle) >= 0; + + private static byte[] Variable(string name, string value) => + [Var, .. Encoding.ASCII.GetBytes(name), Val, .. Encoding.ASCII.GetBytes(value)]; + + [Test] + public async Task AServersSendIsAnsweredWithTheClientsIdentity() + { + var transport = new ScriptedTransport { Greeting = [Iac, Do, NewEnviron, Iac, Sb, NewEnviron, Send, Iac, Se] }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + + await Until(() => Contains(transport.Sent, [Iac, Sb, NewEnviron, Is])); + await Until(() => Contains(transport.Sent, [Iac, Se])); + var sent = transport.Sent; + + await Assert.That(Contains(sent, [Iac, Will, NewEnviron])).IsTrue(); + await Assert.That(Contains(sent, Variable("CLIENT_NAME", "SharpMUTerm"))).IsTrue(); + await Assert.That(Contains(sent, Variable("TERMINAL_TYPE", "XTERM"))).IsTrue(); + await Assert.That(Contains(sent, Variable("MTTS", "2845"))).IsTrue(); + await Assert.That(Contains(sent, Encoding.ASCII.GetBytes("IPADDRESS"))).IsFalse(); + await Assert.That(Contains(sent, Encoding.ASCII.GetBytes("USER"))).IsFalse(); + } + + [Test] + public async Task NothingIsOfferedToAServerThatDoesNotAsk() + { + var transport = new ScriptedTransport { Greeting = Encoding.ASCII.GetBytes("Hello.\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(100); + + await Assert.That(Contains(transport.Sent, [Iac, Will, NewEnviron])).IsFalse(); + } + + [Test] + public async Task TheMttsOverNewEnvironIsTheOneTtypeEndsOn() + { + var identity = TelnetSessionOptions.MnesIdentity(null); + await Assert.That(TelnetSessionOptions.SharpMUTermTerminalTypes).Contains("MTTS " + identity["MTTS"]); + await Assert.That(int.Parse(identity["MTTS"]) & 512).IsEqualTo(512); + await Assert.That(identity.ContainsKey("CHARSET")).IsFalse(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs new file mode 100644 index 00000000..d7865a73 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs @@ -0,0 +1,73 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// The Pueblo handshake from the client's side, as PennMUSH frames it: the server's hello is answered with +/// PUEBLOCLIENT, the server's start sequence switches the connection, and neither line reaches the +/// output. Pueblo is text at a login prompt, so nothing is said to a server that did not say hello first. +/// +public class PuebloHandshakeTests +{ + private const string Hello = "This world is Pueblo 1.10 Enhanced.\r\n"; + private const string Start = "\n"; + + private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text); + + [Test] + public async Task TheHelloIsAnsweredAndTheStartSwitchesTheConnection() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello + "Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var lines = new List(); + var enabled = 0; + session.OutputReceived += (_, e) => { lock (lines) lines.Add(e.Text); }; + session.PuebloEnabled += (_, _) => Interlocked.Increment(ref enabled); + await session.ConnectAsync(); + + await Assert.That(await transport.WaitForSentAsync(Ascii("PUEBLOCLIENT 2.50\r\n"))).IsTrue(); + + transport.SendToClient(Ascii(Start + "bold\r\n")); + for (var i = 0; i < 100 && Volatile.Read(ref enabled) == 0; i++) + { + await Task.Delay(10); + } + + await Assert.That(Volatile.Read(ref enabled)).IsEqualTo(1); + await Task.Delay(50); + string[] seen; + lock (lines) + { + seen = [.. lines]; + } + + await Assert.That(seen).Contains("Welcome"); + await Assert.That(seen).Contains("bold"); + await Assert.That(seen.Any(l => l.Contains("Pueblo", StringComparison.Ordinal) || l.Contains("xch_", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task ASilentServerHearsNothing() + { + var transport = new ScriptedTransport { Greeting = Ascii("Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } + + [Test] + public async Task AWorldThatSaysNoDoesNotAnswer() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello) }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { AnnouncePueblo = false }); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Text/SpellCheckerTests.cs b/tests/SharpMUTerm.Core.Tests/Text/SpellCheckerTests.cs new file mode 100644 index 00000000..06a2e539 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Text/SpellCheckerTests.cs @@ -0,0 +1,97 @@ +using System.Text; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Text; + +/// The composer's spelling: a Hunspell dictionary, a MUSH post's markup left alone, and words added by hand. +public class SpellCheckerTests +{ + internal const string Aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwz'\n"; + internal const string Dic = "7\nhello\nworld\nthe\ncolour\ndon't\nsay\nme\n"; + + internal static SpellChecker Open(string? userWords = null) => + SpellChecker.FromStreams(new MemoryStream(Encoding.UTF8.GetBytes(Dic)), new MemoryStream(Encoding.UTF8.GetBytes(Aff)), "en_GB", userWords); + + private static string[] Marked(SpellChecker checker, string line) => + checker.Misspellings(line).Select(m => line.Substring(m.Start, m.Length)).ToArray(); + + [Test] + public async Task UnknownWordsAreMarkedWhereTheyAre() + { + var checker = Open(); + var line = "hello wrold, the colur"; + + await Assert.That(Marked(checker, line)).IsEquivalentTo(new[] { "wrold", "colur" }); + await Assert.That(checker.Misspellings(line)[0]).IsEqualTo(new Misspelling(6, 5)); + } + + [Test] + public async Task APostsMarkupIsNotProse() + { + var checker = Open(); + + // %r is a break and the word after it is checked; a function name, a dbref, an acronym, a camel-cased + // name and a single letter are not words to mark; an inner apostrophe keeps a word whole. + await Assert.That(Marked(checker, "hello%rworld %rwrold")).IsEquivalentTo(new[] { "wrold" }); + await Assert.That(Marked(checker, "[pemit(me, hello)] #1234 OOC McTavish a don't")).IsEmpty(); + } + + [Test] + public async Task AnAddedWordIsNeverMarkedAgainAndIsKeptForTheNextLaunch() + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-spell-").FullName; + try + { + var words = Path.Combine(root, "dictionaries", "user-words.txt"); + var checker = Open(words); + await Assert.That(checker.Add("Corvid")).IsTrue(); + await Assert.That(checker.Add("two words")).IsFalse(); + + await Assert.That(Marked(checker, "say Corvid")).IsEmpty(); + await Assert.That(Marked(Open(words), "say corvid")).IsEmpty(); + await Assert.That(File.ReadAllText(words)).IsEqualTo("Corvid\n"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task TheWordAtTheCaretIncludesACaretJustPastIt() + { + await Assert.That(SpellChecker.WordAt("say wrold now", 5)).IsEqualTo("wrold"); + await Assert.That(SpellChecker.WordAt("say wrold now", 9)).IsEqualTo("wrold"); + await Assert.That(SpellChecker.WordAt("say now", 4)).IsNull(); + } + + [Test] + public async Task TheCulturesDictionaryWinsThenItsLanguageThenNone() + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-spell-").FullName; + try + { + var empty = Path.Combine(root, "empty"); + var full = Path.Combine(root, "full"); + Directory.CreateDirectory(empty); + Directory.CreateDirectory(full); + foreach (var name in new[] { "de_DE", "en_GB", "en_US" }) + { + File.WriteAllText(Path.Combine(full, name + ".aff"), Aff); + File.WriteAllText(Path.Combine(full, name + ".dic"), Dic); + } + + File.WriteAllText(Path.Combine(full, "fr_FR.dic"), Dic); // no .aff beside it, so not a dictionary + + await Assert.That(SpellChecker.Find([empty, full], "en-US", null)!.Name).IsEqualTo("en_US"); + await Assert.That(SpellChecker.Find([full], "en_AU", null)!.Name).IsEqualTo("en_GB"); + await Assert.That(SpellChecker.Find([full], "fr_FR", null)!.Name).IsEqualTo("de_DE"); + await Assert.That(SpellChecker.Find([full], null, null)!.Name).IsEqualTo("de_DE"); + await Assert.That(SpellChecker.Find([empty, Path.Combine(root, "missing")], "en-US", null)).IsNull(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs b/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs index ff63e25b..8644d204 100644 --- a/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs +++ b/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs @@ -19,6 +19,36 @@ internal sealed class FakeScriptWorld : IScriptWorld public void Print(string text) => Printed.Add(text); + public Dictionary Data { get; } = new(StringComparer.OrdinalIgnoreCase); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); + + public System.Text.Json.Nodes.JsonNode? GameData(string path) => + Data.TryGetValue(path, out var node) ? node?.DeepClone() : null; + + public void SendGmcp(string package, string json) => SentGmcp.Add((package, json)); + + public ScriptRoom? Room { get; set; } + + public Dictionary Paths { get; } = new(StringComparer.Ordinal); + + public List Walks { get; } = new(); + + public ScriptRoom? CurrentRoom() => Room; + + public IReadOnlyList? PathTo(string target) => Paths.GetValueOrDefault(target); + + public string? WalkTo(string target) + { + if (!Paths.ContainsKey(target)) + { + return "the map knows no way there"; + } + + Walks.Add(target); + return null; + } + public void AddTrigger(string pattern, string callbackId) => Triggers.Add((pattern, callbackId)); public void AddAlias(string pattern, string substitution, string callbackId) => diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs new file mode 100644 index 00000000..dc37fcd6 --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs @@ -0,0 +1,97 @@ +using System.Text; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Scripting.Tests; + +/// An in-memory for driving WorldSession tests. +internal sealed class FakeTelnetSession : ITelnetSession +{ + public bool IsConnected { get; private set; } + + public List SentLines { get; } = new(); + + /// What this fake reports as in force; a test that cares sets it and raises the event. + public SessionEncoding CurrentEncoding { get; set; } = + new(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), EncodingSource.Assumed); + + public event EventHandler? OutputReceived; + public event EventHandler? GmcpReceived; + public event EventHandler? MsdpReceived; +#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. + public event EventHandler? MsspReceived; +#pragma warning restore CS0067 + public event EventHandler? EncodingChanged; + public event EventHandler? Disconnected; + public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; + public event EventHandler? MsdpEnabled; + + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + IsConnected = true; + return Task.CompletedTask; + } + + public ValueTask SendLineAsync(string text, CancellationToken cancellationToken = default) + { + SentLines.Add(text); + return ValueTask.CompletedTask; + } + + public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => + ValueTask.CompletedTask; + + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + SentGmcp.Add((package, json)); + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + SentMsdp.Add((variable, value)); + return ValueTask.CompletedTask; + } + + public ValueTask SetWindowSizeAsync(int width, int height) => ValueTask.CompletedTask; + + public Task DisconnectAsync() + { + if (IsConnected) + { + IsConnected = false; + Disconnected?.Invoke(this, new SessionDisconnectedEventArgs(null)); + } + + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + // ---- test drivers ---- + + public void EmitLine(string text) => OutputReceived?.Invoke(this, new TelnetOutputEventArgs(text, isPrompt: false)); + + public void EmitPrompt(string text) => OutputReceived?.Invoke(this, new TelnetOutputEventArgs(text, isPrompt: true)); + + public void EmitGmcp(string package, string json) => + GmcpReceived?.Invoke(this, new GmcpMessageEventArgs(package, json)); + + public void EmitEncoding(SessionEncoding encoding) + { + CurrentEncoding = encoding; + EncodingChanged?.Invoke(this, new SessionEncodingEventArgs(encoding)); + } + + public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); +} diff --git a/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs b/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs new file mode 100644 index 00000000..e01042e7 --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Scripting.Tests; + +/// gmcp.get, msdp.get and gmcp.send: game data in and out of Lua. +public class GameDataApiTests +{ + [Test] + public async Task GmcpGetReturnsTablesAndScalars() + { + var world = new FakeScriptWorld(); + world.Data["Char.Vitals"] = JsonNode.Parse("""{"hp":"450","flags":[true,false],"lvl":12}"""); + using var host = new ScriptHost(world); + + host.Execute(""" + local v = gmcp.get('Char.Vitals') + world.send(v.hp .. ' ' .. tostring(v.flags[1]) .. ' ' .. (v.lvl + 1)) + world.send(tostring(gmcp.get('Nope'))) + """); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "450 true 13", "nil" }); + } + + [Test] + public async Task MsdpGetReadsTheMsdpPrefix() + { + var world = new FakeScriptWorld(); + world.Data["MSDP.HEALTH"] = JsonValue.Create("90"); + using var host = new ScriptHost(world); + + host.Execute("world.send(msdp.get('HEALTH'))"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "90" }); + } + + [Test] + public async Task GmcpSendSerialisesATableAndPassesAStringThrough() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + host.Execute(""" + gmcp.send('Char.Skills.Get', { group = 'combat' }) + gmcp.send('Char.Items.Inv', '') + gmcp.send('Comm.List', { 'a', 'b' }) + gmcp.send('Raw', '{"x":1}') + """); + + await Assert.That(world.SentGmcp).IsEquivalentTo(new[] + { + ("Char.Skills.Get", """{"group":"combat"}"""), + ("Char.Items.Inv", ""), + ("Comm.List", """["a","b"]"""), + ("Raw", """{"x":1}"""), + }); + } + + [Test] + public async Task ATableThatContainsItselfIsRefusedNotLooped() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + await Assert.That(() => host.Execute("local t = {} t.me = t gmcp.send('X', t)")).Throws(); + await Assert.That(world.SentGmcp).IsEmpty(); + } + + [Test] + public async Task SendToReturnsFalseAndWhyWhenTheWorldCannotReachAnotherCharacter() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + host.Execute("local ok, why = world.sendTo('Bob', 'wave') world.send(tostring(ok) .. ' ' .. why)"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "false this client cannot send to another character" }); + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/MapApiTests.cs b/tests/SharpMUTerm.Scripting.Tests/MapApiTests.cs new file mode 100644 index 00000000..3ceaac71 --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/MapApiTests.cs @@ -0,0 +1,47 @@ +namespace SharpMUTerm.Scripting.Tests; + +/// map.current, map.path and map.walk: the recorded map from Lua. +public class MapApiTests +{ + [Test] + public async Task CurrentIsATableOrNil() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + host.Execute("world.send(tostring(map.current()))"); + world.Room = new ScriptRoom(42, "Hall", "Keep"); + host.Execute("local r = map.current(); world.send(r.id .. ' ' .. r.name .. ' ' .. r.area)"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "nil", "42 Hall Keep" }); + } + + [Test] + public async Task PathListsTheCommandsInOrder() + { + var world = new FakeScriptWorld(); + world.Paths["Gate"] = ["n", "e", "open door"]; + using var host = new ScriptHost(world); + + host.Execute("world.send(table.concat(map.path('Gate'), ';')); world.send(tostring(map.path('Nowhere')))"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "n;e;open door", "nil" }); + } + + [Test] + public async Task WalkSaysWhyItDidNotStart() + { + var world = new FakeScriptWorld(); + world.Paths["Gate"] = ["n"]; + using var host = new ScriptHost(world); + + host.Execute(""" + world.send(tostring(map.walk('Gate'))) + local ok, why = map.walk('Nowhere') + world.send(tostring(ok) .. ' ' .. why) + """); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "true", "false the map knows no way there" }); + await Assert.That(world.Walks).IsEquivalentTo(new[] { "Gate" }); + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/ScriptStoreTests.cs b/tests/SharpMUTerm.Scripting.Tests/ScriptStoreTests.cs new file mode 100644 index 00000000..835bc6bb --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/ScriptStoreTests.cs @@ -0,0 +1,131 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Scripting.Tests; + +/// store.*: values a character's scripts keep between runs, bounded, and on disk only when given a folder. +public class ScriptStoreTests +{ + [Test] + public async Task AValueSetInOneHostIsReadByTheNextFromTheSameFile() + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-store-").FullName; + try + { + var path = Path.Combine(root, "Ann.json"); + using (var host = new ScriptHost(new FakeScriptWorld(), new ScriptStore(path))) + { + host.Execute("store.set('kills', 3) store.set('seen', { 'Bob', 'Cy' }) store.set('gone', true) store.set('gone', nil)"); + } + + var world = new FakeScriptWorld(); + using var next = new ScriptHost(world, new ScriptStore(path)); + next.Execute(""" + world.send(store.get('kills') + 1) + world.send(store.get('seen')[2]) + world.send(tostring(store.get('gone'))) + world.send(table.concat(store.keys(), ',')) + """); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "4", "Cy", "nil", "kills,seen" }); + if (!OperatingSystem.IsWindows()) + { + await Assert.That(File.GetUnixFileMode(path)).IsEqualTo(UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task AHostWithNoStoreKeepsValuesInMemoryAndWritesNothing() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + host.Execute("store.set('x', 'y') world.send(store.get('x'))"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "y" }); + await Assert.That(new ScriptStore(null).FilePath).IsNull(); + } + + [Test] + public async Task ASetPastABoundIsRefusedAndChangesNothing() + { + var store = new ScriptStore(null); + await Assert.That(store.Set(new string('k', ScriptStore.MaxKeyLength + 1), System.Text.Json.Nodes.JsonValue.Create(1))).IsNotNull(); + await Assert.That(store.Set("big", System.Text.Json.Nodes.JsonValue.Create(new string('x', ScriptStore.MaxBytes)))).IsNotNull(); + await Assert.That(store.Keys).IsEmpty(); + + using var host = new ScriptHost(new FakeScriptWorld(), store); + await Assert.That(() => host.Execute("store.set('f', function() end)")).Throws(); + await Assert.That(() => host.Execute("store.set('big', string.rep('x', 300000))")).Throws(); + await Assert.That(store.Keys).IsEmpty(); + } + + [Test] + public async Task AFileThatDoesNotParseIsMovedAsideRatherThanOverwritten() + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-store-").FullName; + try + { + var path = Path.Combine(root, "Ann.json"); + File.WriteAllText(path, "{ not json"); + var reports = new List(); + + var store = new ScriptStore(path, reports.Add); + store.Set("a", System.Text.Json.Nodes.JsonValue.Create(1)); + + await Assert.That(File.ReadAllText(path + ".bad")).IsEqualTo("{ not json"); + await Assert.That(reports.Count).IsEqualTo(1); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task TwoCharactersNeverShareAFileAndNoneNamesAPath() + { + var plain = ScriptStore.FileNameFor("Aetherfall.Corvid"); + var slash = ScriptStore.FileNameFor("A/B.C"); + var under = ScriptStore.FileNameFor("A_B.C"); + + await Assert.That(plain).IsEqualTo("Aetherfall.Corvid.json"); + await Assert.That(slash).IsNotEqualTo(under); + await Assert.That(slash).DoesNotContain("/"); + await Assert.That(ScriptStore.FileNameFor("..")).DoesNotContain(".."); + } + + [Test] + public async Task TheStoreOutlivesAReloadOfTheSessionsScripts() + { + var scripts = Directory.CreateTempSubdirectory("sharpmuterm-scripts-").FullName; + var data = Directory.CreateTempSubdirectory("sharpmuterm-store-").FullName; + try + { + File.WriteAllText(Path.Combine(scripts, "main.lua"), "store.set('loads', (store.get('loads') or 0) + 1)"); + var set = new TriggerSet { Name = "s", ScriptFiles = { "main.lua" } }; + var session = new WorldSession( + new WorldDefinition { Name = "T", Host = "h", Port = 1 }, + new CharacterDefinition { Name = "Ann" }, + triggerSets: new[] { set }, + sessionFactory: _ => new FakeTelnetSession()); + using var sessionScripts = new SessionScripts(session, scripts, _ => { }, storeRoot: data); + + sessionScripts.Reload(); + sessionScripts.Reload(); + + await Assert.That(sessionScripts.Store.Get("loads")!.GetValue()).IsEqualTo(2); + await Assert.That(File.Exists(Path.Combine(data, "T.Ann.json"))).IsTrue(); + } + finally + { + Directory.Delete(scripts, recursive: true); + Directory.Delete(data, recursive: true); + } + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/SessionScriptsTests.cs b/tests/SharpMUTerm.Scripting.Tests/SessionScriptsTests.cs new file mode 100644 index 00000000..82197a44 --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/SessionScriptsTests.cs @@ -0,0 +1,217 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; + +namespace SharpMUTerm.Scripting.Tests; + +/// +/// A session's Lua, end to end: files named by a trigger set, loaded from under the scripts folder, called by +/// the rules that name their functions. Until this existed no was ever built outside +/// these tests, so a configured script field did nothing. +/// +public class SessionScriptsTests +{ + private sealed class Fixture : IDisposable + { + public Fixture(string lua, Action? configure = null) + { + Root = Directory.CreateTempSubdirectory("sharpmuterm-scripts-").FullName; + File.WriteAllText(Path.Combine(Root, "main.lua"), lua); + Set = new TriggerSet { Name = "s", ScriptFiles = { "main.lua" } }; + configure?.Invoke(Set); + Session = new WorldSession( + new WorldDefinition { Name = "T", Host = "h", Port = 1 }, + triggerSets: new[] { Set }, + sessionFactory: _ => Telnet); + Scripts = new SessionScripts(Session, Root, Reports.Add); + } + + public string Root { get; } + + public TriggerSet Set { get; } + + public FakeTelnetSession Telnet { get; } = new(); + + public WorldSession Session { get; } + + public SessionScripts Scripts { get; } + + public List Reports { get; } = new(); + + public void Dispose() + { + Scripts.Dispose(); + Directory.Delete(Root, recursive: true); + } + } + + [Test] + public async Task AConfiguredTriggerCallsTheNamedFunctionWithItsCaptures() + { + using var f = new Fixture( + "function on_tell(line, who, what) world.send('reply ' .. who .. ' ' .. what) end", + set => set.Triggers.Add(new Trigger + { + Name = "tell", + Pattern = @"^(\w+) tells you '(.*)'$", + Actions = new TriggerActions { ScriptCallback = "on_tell" }, + })); + await Assert.That(f.Scripts.Reload()).IsEqualTo(1); + await f.Session.ConnectAsync(); + + f.Telnet.EmitLine("Ann tells you 'hi'"); + + await Assert.That(f.Telnet.SentLines).Contains("reply Ann hi"); + await Assert.That(f.Reports).IsEmpty(); + } + + [Test] + public async Task ATableFunctionIsFoundByItsDottedName() + { + using var f = new Fixture( + "combat = {} function combat.flee() world.send('flee') end", + set => set.Macros.Add(new Macro { Key = "F12", ScriptCallback = "combat.flee" })); + f.Scripts.Reload(); + await f.Session.ConnectAsync(); + + await f.Session.HandleKeyAsync("F12"); + + await Assert.That(f.Telnet.SentLines).IsEquivalentTo(new[] { "flee" }); + await Assert.That(f.Scripts.Defines("combat.flee")).IsTrue(); + await Assert.That(f.Scripts.Defines("combat.fight")).IsFalse(); + } + + /// A rule naming a function nobody wrote says so — once, not on every line it matches. + [Test] + public async Task AMissingFunctionIsReportedOnce() + { + using var f = new Fixture( + "-- nothing here", + set => set.Triggers.Add(new Trigger + { + Name = "any", + Pattern = "^x", + Actions = new TriggerActions { ScriptCallback = "nope" }, + })); + f.Scripts.Reload(); + await f.Session.ConnectAsync(); + + f.Telnet.EmitLine("x1"); + f.Telnet.EmitLine("x2"); + + await Assert.That(f.Reports).HasSingleItem(); + await Assert.That(f.Reports[0]).Contains("nope"); + } + + /// Re-running a file over its own registrations would fire every trigger.add rule twice. + [Test] + public async Task ReloadingDoesNotDoubleWhatTheScriptRegistered() + { + using var f = new Fixture("trigger.add('^ping$', function() world.send('pong') end)"); + f.Scripts.Reload(); + f.Scripts.Reload(); + await f.Session.ConnectAsync(); + + f.Telnet.EmitLine("ping"); + + await Assert.That(f.Telnet.SentLines).IsEquivalentTo(new[] { "pong" }); + await Assert.That(f.Session.Triggers.Triggers).HasSingleItem(); + } + + [Test] + public async Task SyncFilesRebuildsOnlyWhenTheListChanges() + { + using var f = new Fixture("count = (count or 0) + 1"); + f.Scripts.Reload(); + + await Assert.That(f.Scripts.SyncFiles()).IsFalse(); + + File.WriteAllText(Path.Combine(f.Root, "two.lua"), "x = 1"); + f.Set.ScriptFiles.Add("two.lua"); + f.Session.ReloadAutomation(new[] { f.Set }); + + await Assert.That(f.Scripts.SyncFiles()).IsTrue(); + await Assert.That(f.Scripts.Files).IsEquivalentTo(new[] { "main.lua", "two.lua" }); + } + + /// + /// A trigger set is shared configuration, and later a package can bring one. Neither may make the client + /// read a file outside the scripts folder, by an absolute path or by climbing out of it. + /// + [Test] + [Arguments("../outside.lua")] + [Arguments("sub/../../outside.lua")] + [Arguments("/etc/passwd")] + public async Task APathOutsideTheRootIsRefusedAndReported(string file) + { + using var f = new Fixture("x = 1", set => set.ScriptFiles.Add(file)); + + var loaded = f.Scripts.Reload(); + + await Assert.That(loaded).IsEqualTo(1); + await Assert.That(f.Reports).HasSingleItem(); + await Assert.That(f.Reports[0]).Contains("outside the scripts folder"); + } + + [Test] + public async Task ResolveKeepsNamesInsideTheRoot() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + + await Assert.That(SessionScripts.Resolve(root, "a.lua")).IsEqualTo(Path.Combine(root, "a.lua")); + await Assert.That(SessionScripts.Resolve(root, "lib/b.lua")).IsEqualTo(Path.Combine(root, "lib", "b.lua")); + await Assert.That(SessionScripts.Resolve(root, Path.Combine(root, "c.lua"))).IsEqualTo(Path.Combine(root, "c.lua")); + await Assert.That(SessionScripts.Resolve(root, "../rootkit/d.lua")).IsNull(); + await Assert.That(SessionScripts.Resolve(root, "")).IsNull(); + } + + [Test] + public async Task AFileThatFailsToLoadIsReportedAndTheRestStillLoad() + { + using var f = new Fixture("function ok() end", set => set.ScriptFiles.Insert(0, "missing.lua")); + + var loaded = f.Scripts.Reload(); + + await Assert.That(loaded).IsEqualTo(1); + await Assert.That(f.Reports).HasSingleItem(); + await Assert.That(f.Reports[0]).Contains("missing.lua"); + await Assert.That(f.Scripts.Defines("ok")).IsTrue(); + } + + /// The sandbox holds for a file loaded this way exactly as for inline code. + [Test] + public async Task ALoadedFileIsSandboxed() + { + using var f = new Fixture("os.execute('true')"); + + f.Scripts.Reload(); + + await Assert.That(f.Reports).HasSingleItem(); + } + + [Test] + public async Task APackageScriptLoadsThroughThePackageResolverAndNowhereElse() + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-pkgscript-").FullName; + try + { + var lua = Path.Combine(root, "status.lua"); + File.WriteAllText(lua, "function pkg_hello() world.send('hello') end"); + var set = new TriggerSet { Name = "pkg", ScriptFiles = { "package:demo/status.lua", "package:gone/x.lua" } }; + var telnet = new FakeTelnetSession(); + var session = new WorldSession( + new WorldDefinition { Name = "T", Host = "h", Port = 1 }, triggerSets: new[] { set }, sessionFactory: _ => telnet); + var reports = new List(); + using var scripts = new SessionScripts( + session, root, reports.Add, name => name == "package:demo/status.lua" ? lua : null); + + await Assert.That(scripts.Reload()).IsEqualTo(1); + await Assert.That(scripts.Defines("pkg_hello")).IsTrue(); + await Assert.That(reports.Single()).Contains("not in an installed package"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/AnnounceTests.cs b/tests/SharpMUTerm.Tui.Tests/AnnounceTests.cs new file mode 100644 index 00000000..ba5d5293 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/AnnounceTests.cs @@ -0,0 +1,101 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Accessibility; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// What a screen reader is told: output as it arrives, the client's messages, the window in front. +[NotInParallel] +public class AnnounceTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private sealed class Recorder : IAnnouncer + { + public List<(string Text, AnnouncementKind Kind)> Heard { get; } = []; + + public void Announce(string text, AnnouncementKind kind) + { + lock (Heard) + { + Heard.Add((text, kind)); + } + } + } + + private static async Task<(SharpMUTermApp App, Recorder Heard, RecordingTelnetSession Wire)> Open(IAnnouncer? announcer) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Mush", Host = "mush.example", Port = 4201 }; + world.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new() }); + config.Worlds.Add(world); + var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 32), null, null, null) with { Announcer = announcer }; + var app = new SharpMUTermApp(config, host); + var wire = new RecordingTelnetSession(); + app.TelnetFactory = _ => wire; + app.DispatchCommand(CommandIds.Character("Mush.Ann")); + await app.FindSession("Mush.Ann")!.ConnectAsync(); + app.RenderNextFrame(); + return (app, announcer as Recorder ?? new Recorder(), wire); + } + + [Test] + public async Task OutputIsAnnouncedAsPlainTextInArrivalOrder() + { + var (app, heard, wire) = await Open(new Recorder()); + await using var _ = app; + + wire.Receive("\x1b[1;31mA red\x1b[0m line."); + wire.Receive("And another."); + app.RenderNextFrame(); + + var output = heard.Heard.Where(h => h.Kind == AnnouncementKind.Output).Select(h => h.Text).ToList(); + await Assert.That(output.IndexOf("A red line.")).IsGreaterThanOrEqualTo(0); + await Assert.That(output.IndexOf("And another.")).IsGreaterThan(output.IndexOf("A red line.")); + await Assert.That(output.Any(t => t.Contains('\x1b'))).IsFalse(); + } + + [Test] + public async Task AQuietWindowIsNotReadButTheClientsMessagesStillAre() + { + var (app, heard, wire) = await Open(new Recorder()); + await using var _ = app; + var titles = app.BuildCatalog().Select(i => i.Title).ToArray(); + await Assert.That(titles).Contains("Stop reading this window aloud"); + + app.DispatchCommand("a11y:toggle-window"); + wire.Receive("Unheard."); + app.RenderNextFrame(); + + await Assert.That(heard.Heard.Any(h => h.Text == "Unheard.")).IsFalse(); + await Assert.That(heard.Heard[^1].Kind).IsEqualTo(AnnouncementKind.Notice); + await Assert.That(app.BuildCatalog().Select(i => i.Title)).Contains("Read this window aloud"); + } + + [Test] + public async Task AHostWithNoAnnouncerOffersNothingToToggle() + { + var (app, _, _) = await Open(null); + await using var _app = app; + + await Assert.That(app.BuildCatalog().Any(i => i.Id == "a11y:toggle-window")).IsFalse(); + } + + [Test] + public async Task ThePlainHostMarksTheClientsOwnLines() + { + var output = new StringWriter(); + var announcer = new PlainAnnouncer(output); + + announcer.Announce("You see a troll.", AnnouncementKind.Output); + announcer.Announce("nothing was sent", AnnouncementKind.Notice); + announcer.Announce("Chat", AnnouncementKind.Focus); + + await Assert.That(output.ToString().ReplaceLineEndings("\n")) + .IsEqualTo("You see a troll.\n[client] nothing was sent\n[window] Chat\n"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/CompletionTests.cs b/tests/SharpMUTerm.Tui.Tests/CompletionTests.cs new file mode 100644 index 00000000..06870df2 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/CompletionTests.cs @@ -0,0 +1,80 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// ⇥ on the command line: completes from what the bar's own connection has seen, and cycles. +[NotInParallel] +public class CompletionTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static readonly ConsoleKeyInfo Tab = new('\t', ConsoleKey.Tab, false, false, false); + + private static void Type(SharpMUTermApp app, string text) + { + foreach (var c in text) + { + app.SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + } + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Telnet)> Connected() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Shire", Host = "shire.example.org", Port = 4000 }; + world.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + config.Worlds.Add(world); + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 32)); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + await app.BindWorldWithoutConnecting(world).ConnectAsync(); + app.RenderNextFrame(); + return (app, telnet); + } + + [Test] + public async Task TabCompletesTheLastWordAndCyclesBackToWhatWasTyped() + { + var (app, telnet) = await Connected(); + telnet.Receive("Gandalf and the gatekeeper are here."); + Type(app, "say hi ga"); + + app.SimulateKey(Tab); + await Assert.That(app.ArmedInputText).IsEqualTo("say hi gatekeeper"); + app.SimulateKey(Tab); + await Assert.That(app.ArmedInputText).IsEqualTo("say hi Gandalf"); + app.SimulateKey(Tab); + await Assert.That(app.ArmedInputText).IsEqualTo("say hi ga"); + await Assert.That(telnet.Lines).IsEmpty(); + } + + [Test] + public async Task TypingEndsTheCycle() + { + var (app, telnet) = await Connected(); + telnet.Receive("Gandalf and the gatekeeper are here."); + Type(app, "ga"); + app.SimulateKey(Tab); + Type(app, " and gan"); + + app.SimulateKey(Tab); + + await Assert.That(app.ArmedInputText).IsEqualTo("gatekeeper and Gandalf"); + } + + [Test] + public async Task NothingToCompleteLeavesTheTextAlone() + { + var (app, telnet) = await Connected(); + telnet.Receive("Nobody is here."); + Type(app, "say zz"); + + app.SimulateKey(Tab); + + await Assert.That(app.ArmedInputText).IsEqualTo("say zz"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/ComposeSpellingTests.cs b/tests/SharpMUTerm.Tui.Tests/ComposeSpellingTests.cs new file mode 100644 index 00000000..f8f457d8 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/ComposeSpellingTests.cs @@ -0,0 +1,134 @@ +using System.Text; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Telnet; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Spelling in the F1 composer: unknown words painted in the warning colour, ⌥W to add one, and a header that +/// says which dictionary is in use or that there is none. +/// +[NotInParallel] +public class ComposeSpellingTests +{ + private const int Width = 120; + private const int Height = 32; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo Key(ConsoleKey key, ConsoleModifiers modifiers = 0) => + new('\0', key, modifiers.HasFlag(ConsoleModifiers.Shift), + modifiers.HasFlag(ConsoleModifiers.Alt), modifiers.HasFlag(ConsoleModifiers.Control)); + + private static SpellChecker Dictionary() => + SpellChecker.FromStreams( + new MemoryStream(Encoding.UTF8.GetBytes("3\nhello\nthe\nworld\n")), + new MemoryStream(Encoding.UTF8.GetBytes("SET UTF-8\n")), + "en_GB"); + + private static SharpMUTermApp App(Func? spelling) + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(new AppConfiguration(), Headless, new HeadlessConsoleDriver(Width, Height), spelling: spelling); + } + + private static Rgb? ForegroundOf(SharpMUTermApp app, string word) + { + var frame = app.RenderWholeFrame(); + var rows = FrameGrid.Decode(frame, Width, Height); + var cells = FrameGrid.Cells(frame, Width, Height); + for (var row = 0; row < rows.Count; row++) + { + var column = rows[row].IndexOf(word, StringComparison.Ordinal); + if (column >= 0) + { + return cells[(row, column)].Foreground; + } + } + + throw new InvalidOperationException($"'{word}' is not on screen"); + } + + private static readonly Rgb Warn = new(0xff, 0x6b, 0x6b); + + [Test] + public async Task AnUnknownWordIsPaintedInTheWarningColourAndAKnownOneIsNot() + { + var app = App(Dictionary); + app.SimulateKey(Key(ConsoleKey.F1)); + app.Composer.SimulateTyping("hello wrold"); + + await Assert.That(app.Composer.HeaderMarkup).Contains("en_GB"); + await Assert.That(ForegroundOf(app, "wrold")).IsEqualTo(Warn); + await Assert.That(ForegroundOf(app, "hello")).IsNotEqualTo(Warn); + } + + [Test] + public async Task AltWAddsTheWordAtTheCaretAndItIsNoLongerMarked() + { + var checker = Dictionary(); + var app = App(() => checker); + app.SimulateKey(Key(ConsoleKey.F1)); + app.Composer.SimulateTyping("Corvid"); + await Assert.That(ForegroundOf(app, "Corvid")).IsEqualTo(Warn); + + await Assert.That(app.Composer.SimulateKey(Key(ConsoleKey.W, ConsoleModifiers.Alt))).IsTrue(); + + await Assert.That(checker.AddedWords).Contains("Corvid"); + await Assert.That(ForegroundOf(app, "Corvid")).IsNotEqualTo(Warn); + await Assert.That(app.Composer.Body).IsEqualTo("Corvid"); + } + + [Test] + public async Task WithNoDictionaryTheHeaderSaysSoAndAltWIsNotOffered() + { + var app = App(() => null); + app.SimulateKey(Key(ConsoleKey.F1)); + app.Composer.SimulateTyping("wrold"); + + await Assert.That(app.Composer.HeaderMarkup).Contains("no dictionary"); + await Assert.That(app.Composer.SimulateKey(Key(ConsoleKey.W, ConsoleModifiers.Alt))).IsFalse(); + var rows = FrameGrid.Decode(app.RenderWholeFrame(), Width, Height); + await Assert.That(rows.Any(r => r.Contains("⌥W", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task TheDictionaryIsOpenedOnceAndOnlyWhenTheComposerIs() + { + var opened = 0; + var app = App(() => { opened++; return Dictionary(); }); + await Assert.That(opened).IsEqualTo(0); + + app.SimulateKey(Key(ConsoleKey.F1)); + app.SimulateKey(Key(ConsoleKey.F1)); + app.SimulateKey(Key(ConsoleKey.F1)); + + await Assert.That(opened).IsEqualTo(1); + } + + [Test] + public async Task AMooEditIsNotSpellChecked() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Moo", Host = "moo.example.org", Port = 7777 }; + world.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + config.Worlds.Add(world); + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(Width, Height), spelling: Dictionary); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + var session = app.BindWorldWithoutConnecting(world); + await session.ConnectAsync(); + app.RenderNextFrame(); + + telnet.ReceiveMooEdit(new MooEdit("#98.look", "Test:look", "moo-code", ["player:tell(\"Hi.\");"])); + + await Assert.That(app.Composer.IsOpen).IsTrue(); + await Assert.That(app.Composer.Spelling).IsNull(); + await Assert.That(ForegroundOf(app, "player")).IsNotEqualTo(Warn); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/EditGrabTests.cs b/tests/SharpMUTerm.Tui.Tests/EditGrabTests.cs new file mode 100644 index 00000000..0dd0b80a --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/EditGrabTests.cs @@ -0,0 +1,94 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// A MUSH's edit grab on the command line: the text lands in the bar of the window it came from, nothing is +/// sent, and a line being typed is kept in history rather than lost. +/// +[NotInParallel] +public class EditGrabTests +{ + private const string Prefix = "FugueEdit >"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static void Type(SharpMUTermApp app, string text) + { + foreach (var c in text) + { + app.SimulateKey(Key(c)); + } + } + + private sealed record Two(SharpMUTermApp App, RecordingTelnetSession Ann, RecordingTelnetSession Bob); + + private static async Task TwoWorlds() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var ares = new WorldDefinition { Name = "Ares", Host = "ares.example.org", Port = 4201, GrabPrefix = Prefix }; + ares.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + var other = new WorldDefinition { Name = "Other", Host = "other.example.org", Port = 4201, GrabPrefix = Prefix }; + other.Characters.Add(new CharacterDefinition { Name = "Bob", Logging = new LoggingSettings() }); + config.Worlds.Add(ares); + config.Worlds.Add(other); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 32)); + var ann = new RecordingTelnetSession(); + var bob = new RecordingTelnetSession(); + app.TelnetFactory = options => options.Host.StartsWith("ares", StringComparison.Ordinal) ? ann : bob; + foreach (var key in new[] { "Other.Bob", "Ares.Ann" }) + { + await Assert.That(app.DispatchCommand(Core.Commands.CommandIds.Character(key))).IsTrue(); + await app.FindSession(key)!.ConnectAsync(); + } + + app.RenderNextFrame(); + return new Two(app, ann, bob); + } + + [Test] + public async Task AGrabFillsTheCommandLineAndSendsNothing() + { + var two = await TwoWorlds(); + + two.Ann.Receive($"{Prefix} &desc me=A tall figure in grey."); + + await Assert.That(two.App.ArmedInputText).IsEqualTo("&desc me=A tall figure in grey."); + await Assert.That(two.Ann.Lines).IsEmpty(); + } + + [Test] + public async Task ALineBeingTypedIsKeptInHistory() + { + var two = await TwoWorlds(); + Type(two.App, "say half a thought"); + + two.Ann.Receive($"{Prefix} &desc me=x"); + await Assert.That(two.App.ArmedInputText).IsEqualTo("&desc me=x"); + + two.App.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, true, false)); + await Assert.That(two.App.ArmedInputText).IsEqualTo("say half a thought"); + } + + [Test] + public async Task AGrabForAWindowNotInFrontWaitsForIt() + { + var two = await TwoWorlds(); + Type(two.App, "for Ann"); + + two.Bob.Receive($"{Prefix} &desc me=Bob's"); + + // Ann's bar is untouched: a grab never lands on another character's command line. + await Assert.That(two.App.ArmedInputText).IsEqualTo("for Ann"); + await Assert.That(two.App.DispatchCommand(Core.Commands.CommandIds.Character("Other.Bob"))).IsTrue(); + await Assert.That(two.App.ArmedInputText).IsEqualTo("&desc me=Bob's"); + await Assert.That(two.Bob.Lines).IsEmpty(); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs index 1e44ebc0..bd4847c3 100644 --- a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs @@ -40,7 +40,7 @@ public class FrameContrastTests private static readonly string[] Views = [ "", "freeze", "away", "highlight", "scrollback", "links", "mxp-image", "mxp-figure", "connections", "tint", "tint-input", - "characters", "compose", "mssp", "web", "spawn", "split", "menu", "quit", "worlds", "triggers", + "characters", "compose", "moo-edit", "mssp", "web", "spawn", "split", "menu", "quit", "worlds", "triggers", "logging", "startup", "history", "prefix-panel", "keypad", // The selection band is the one plane this client invents rather than derives from a pane, and @@ -48,6 +48,13 @@ public class FrameContrastTests // most to lose from a theme it was not measured against. "selection", "input-selection", + // The meters are the status row's only cells whose colour depends on what a server sent (a low + // value wears the notice ink), and the inspector is a modal of its own. + "meters", "gamedata", "map", "map-walking", "map-split", + + // The package surfaces are a modal of their own, with a warning ink on the review. + "packages", "package-review", "package-browse", "send-menu", + // The prompt row is the only thing this client paints outside a pane that carries the // *game's* colours rather than its own, so the floor is applied against the idle input band // instead of the reading plane. A band nothing renders in a test is a band nobody checks. diff --git a/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs b/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs new file mode 100644 index 00000000..ae89ddf7 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs @@ -0,0 +1,169 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// GMCP and MSDP from the client's side: the status row's meters and the ⌃P inspector. The client used to +/// keep nine fixed Char.* keys from GMCP and draw none of them. +/// +[NotInParallel] +public class GameDataTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static AppConfiguration Config() + { + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() } }, + }); + return config; + } + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Wire)> Open(AppConfiguration? config = null) + { + Console.SetIn(TextReader.Null); + var wire = new RecordingTelnetSession(); + var clock = new ManualTimeProvider(); + var app = new SharpMUTermApp(config ?? Config(), Headless, new HeadlessConsoleDriver(160, 32), time: clock); + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(Ann))) + { + throw new InvalidOperationException("the app would not switch to Ann"); + } + + await app.FindSession(Ann)!.ConnectAsync(); + + // The switch's notice holds the row; the meters are on the resting line under it. + clock.Advance(SharpMUTermApp.NoticeDuration + TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + return (app, wire); + } + + [Test] + public async Task AWorldWithNoGameDataShowsNoMeters() + { + var (app, _) = await Open(); + await using var _app = app; + + await Assert.That(MarkupText.Plain(app.StatusMarkup)).DoesNotContain("hp"); + } + + [Test] + public async Task GmcpVitalsAppearOnTheStatusRow() + { + var (app, wire) = await Open(); + await using var _app = app; + + wire.ReceiveGmcp("Char.Vitals", """{"hp":"412","maxhp":"500"}"""); + app.RenderNextFrame(); + + var row = MarkupText.Plain(app.StatusMarkup); + await Assert.That(row).Contains("hp 412/500"); + await Assert.That(row).DoesNotContain("mp"); // no value, no meter + } + + [Test] + public async Task AnMsdpGameFillsTheSameMeterAndIsAskedForIt() + { + var (app, wire) = await Open(); + await using var _app = app; + + wire.RaiseMsdpEnabled(); + wire.ReceiveMsdp("""{"HEALTH":"90","HEALTH_MAX":"100"}"""); + app.RenderNextFrame(); + + await Assert.That(MarkupText.Plain(app.StatusMarkup)).Contains("hp 90/100"); + await Assert.That(wire.SentMsdp).Contains(("REPORT", "HEALTH")); + await Assert.That(wire.SentMsdp).Contains(("REPORT", "HEALTH_MAX")); + } + + /// The 99 → 100 lesson: a value from the wire may not change how wide the row is. + [Test] + [Arguments("9")] + [Arguments("10")] + [Arguments("100")] + [Arguments("99999")] + [Arguments("123456789")] + public async Task AMetersWidthDoesNotDependOnItsValue(string hp) + { + var meters = new List { new() { Label = "hp", Value = "Char.Vitals.hp", Max = "Char.Vitals.maxhp" } }; + var baseline = new GameDataStore(); + baseline.SetGmcp("Char.Vitals", """{"hp":"1","maxhp":"1"}"""); + var store = new GameDataStore(); + store.SetGmcp("Char.Vitals", $$"""{"hp":"{{hp}}","maxhp":"{{hp}}"}"""); + + var width = MarkupText.VisibleLength(StatusMeterRenderer.Render(meters, store, "#ffff00")); + + await Assert.That(width).IsEqualTo(MarkupText.VisibleLength(StatusMeterRenderer.Render(meters, baseline, "#ffff00"))); + } + + [Test] + public async Task ALowValueWearsTheWarningInk() + { + var meters = new List { new() { Label = "hp", Value = "Char.Vitals.hp", Max = "Char.Vitals.maxhp" } }; + var store = new GameDataStore(); + store.SetGmcp("Char.Vitals", """{"hp":"20","maxhp":"100"}"""); + + await Assert.That(StatusMeterRenderer.Render(meters, store, "#abcdef")).Contains("#abcdef"); + + store.SetGmcp("Char.Vitals", """{"hp":"80"}"""); + await Assert.That(StatusMeterRenderer.Render(meters, store, "#abcdef")).DoesNotContain("#abcdef"); + } + + [Test] + public async Task TheInspectorOpensOnTheFocusedConnection() + { + var (app, wire) = await Open(); + await using var _app = app; + wire.ReceiveGmcp("Room.Info", """{"num":1,"name":"Hall"}"""); + + await Assert.That(app.BuildCatalog().Select(i => i.Id)).Contains("term:gamedata"); + await Assert.That(app.DispatchCommand("term:gamedata")).IsTrue(); + await Assert.That(app.GameDataIsOpen).IsTrue(); + + app.DispatchCommand("term:gamedata"); + await Assert.That(app.GameDataIsOpen).IsFalse(); + } + + [Test] + public async Task F7EditsTheMetersAsOneLine() + { + var text = new TextSettings(); + var screen = OptionsScreenRenderer.TextAnsiScreen(text); + var model = OptionsScreenRenderer.Model(screen); + var field = Enumerable.Range(0, model.PaneCount) + .SelectMany(p => Enumerable.Range(0, model.RowCount(p)).Select(r => model.RowAt(p, r))) + .SelectMany(r => Enumerable.Range(0, r.FieldCount).Select(i => r.FieldAt(i))) + .Where(f => f is not null) + .Select(f => f!.Value) + .Single(f => f.Label == "meters"); + + await Assert.That(new ScreenEdits().Apply(field, "hp=Char.Vitals.hp/Char.Vitals.maxhp; gold=Char.Status.gold; broken")).IsNull(); + + await Assert.That(text.StatusMeters.Select(m => m.Label)).IsEquivalentTo(new[] { "hp", "gold" }); + await Assert.That(text.StatusMeters[1].Max).IsNull(); + } + + [Test] + public async Task F5KeepsOnlyModulesThatParse() + { + var world = new WorldDefinition { Name = "W", Host = "h", Port = 1 }; + + WorldsScreenRenderer.SplitModules(world, "IRE.Rift 1, nonsense here, Room 2, ire.rift 3"); + + await Assert.That(world.GmcpModules).IsEquivalentTo(new[] { "IRE.Rift 1", "Room 2" }); + await Assert.That(WorldsScreenRenderer.JoinModules(world)).IsEqualTo("IRE.Rift 1, Room 2"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/MapRecordingTests.cs b/tests/SharpMUTerm.Tui.Tests/MapRecordingTests.cs new file mode 100644 index 00000000..f6ad8e01 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/MapRecordingTests.cs @@ -0,0 +1,47 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// A connected session's GMCP rooms reach its map, and the map is kept in the store the app was given. +[NotInParallel] +public class MapRecordingTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + [Test] + public async Task GmcpRoomsBuildTheSessionsMap() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() } }, + }); + var wire = new RecordingTelnetSession(); + var store = new MapStore(); + await using var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(160, 32), maps: store); + app.TelnetFactory = _ => wire; + await Assert.That(app.DispatchCommand(CommandIds.Character(Ann))).IsTrue(); + var session = app.FindSession(Ann)!; + await session.ConnectAsync(); + + wire.ReceiveGmcp("Room.Info", """{"num":1,"name":"Hall","exits":{"n":2}}"""); + await session.SendUserInputAsync("n"); + wire.ReceiveGmcp("Room.Info", """{"num":2,"name":"Gallery","exits":{"s":1}}"""); + + var recorder = app.MapFor(session)!; + await Assert.That(recorder.CurrentRoom!.Name).IsEqualTo("Gallery"); + await Assert.That(store.For("convergence.example", 4201).Rooms.Count).IsEqualTo(2); + await Assert.That(PathFinder.Find(recorder.Map, 2, 1)!.Single().Command).IsEqualTo("s"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/MapWindowTests.cs b/tests/SharpMUTerm.Tui.Tests/MapWindowTests.cs new file mode 100644 index 00000000..abf46f1c --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/MapWindowTests.cs @@ -0,0 +1,178 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Mapping; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// The map window: drawn from the connection's map, walked by a click or /walk. +[NotInParallel] +public class MapWindowTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Wire)> Open(ManualTimeProvider? clock = null) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Map = new MapSettings { WalkFailurePattern = "can't go that way" }, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() } }, + }); + var wire = new RecordingTelnetSession(); + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(160, 32), time: clock); + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(Ann))) + { + throw new InvalidOperationException("the app would not switch to Ann"); + } + + await app.FindSession(Ann)!.ConnectAsync(); + wire.ReceiveGmcp("Room.Info", """{"num":1,"name":"Hall","area":"Keep","exits":{"n":2}}"""); + wire.ReceiveGmcp("Room.Info", """{"num":2,"name":"Gallery","area":"Keep","exits":{"s":1,"e":3}}"""); + wire.ReceiveGmcp("Room.Info", """{"num":3,"name":"Tower","area":"Keep","exits":{"w":2}}"""); + wire.ReceiveGmcp("Room.Info", """{"num":1,"name":"Hall","area":"Keep","exits":{"n":2}}"""); + return (app, wire); + } + + private static string MapId => SharpMUTermApp.MapWindowId(Ann); + + [Test] + public async Task ShowMapOpensTheConnectionsMapInFront() + { + var (app, _) = await Open(); + await using var _app = app; + + await Assert.That(app.BuildCatalog().Select(i => i.Id)).Contains("term:map"); + await Assert.That(app.DispatchCommand("term:map")).IsTrue(); + app.RenderNextFrame(); + + await Assert.That(app.WindowOwnerOf(MapId)).IsEqualTo(Ann); + await Assert.That(app.ActiveWindowId()).IsEqualTo(MapId); + var plain = app.MapMarkup(MapId).Select(MarkupText.Plain).ToList(); + await Assert.That(plain[0]).IsEqualTo("Map · Keep · z 0"); + await Assert.That(plain.Any(l => l.Contains("[@]"))).IsTrue(); + await Assert.That(app.MapMarkup(MapId).Count(l => l.Contains("[link=mux:map:"))).IsGreaterThan(0); + } + + [Test] + public async Task ClickingARoomWalksThereAStepAtATime() + { + var (app, wire) = await Open(); + await using var _app = app; + app.DispatchCommand("term:map"); + var before = wire.Lines.Count; + + app.SimulateMapClick(MapId, "mux:map:3"); + await Assert.That(wire.Lines.Skip(before)).IsEquivalentTo(new[] { "n" }); + await Assert.That(app.BuildCatalog().Select(i => i.Id)).Contains("term:walk-stop"); + + wire.ReceiveGmcp("Room.Info", """{"num":2,"name":"Gallery","area":"Keep","exits":{"s":1,"e":3}}"""); + await Assert.That(wire.Lines.Skip(before)).IsEquivalentTo(new[] { "n", "e" }); + await Assert.That(MarkupText.Plain(app.MapMarkup(MapId)[0])).Contains("walking, 1 to go"); + + wire.ReceiveGmcp("Room.Info", """{"num":3,"name":"Tower","area":"Keep","exits":{"w":2}}"""); + await Assert.That(app.SessionMapFor(app.FindSession(Ann)!)!.Walk.IsWalking).IsFalse(); + } + + /// The map's handler answers its own scheme and nothing else: a send payload is not a room. + [Test] + public async Task AMapClickIsNeverASend() + { + var (app, wire) = await Open(); + await using var _app = app; + app.DispatchCommand("term:map"); + var before = wire.Lines.Count; + + app.SimulateMapClick(MapId, "mux:send:@shutdown"); + + await Assert.That(wire.Lines.Count).IsEqualTo(before); + } + + [Test] + public async Task WalkByNameAndTheFailureLineStopsIt() + { + var (app, wire) = await Open(); + await using var _app = app; + var before = wire.Lines.Count; + + Type(app, "/walk tower"); + await Assert.That(wire.Lines.Skip(before)).IsEquivalentTo(new[] { "n" }); + + wire.Receive("You can't go that way.\r\n"); + await Assert.That(app.SessionMapFor(app.FindSession(Ann)!)!.Walk.IsWalking).IsFalse(); + } + + [Test] + public async Task ALockedRoomIsNotWalkedThrough() + { + var (app, wire) = await Open(); + await using var _app = app; + var before = wire.Lines.Count; + + Type(app, "/map lock Gallery"); + Type(app, "/walk 3"); + + await Assert.That(wire.Lines.Skip(before)).IsEmpty(); + await Assert.That(app.SessionMapFor(app.FindSession(Ann)!)!.Map.Room(2)!.Locked).IsTrue(); + } + + [Test] + public async Task TheRendererDrawsRoomsLinksAndStubs() + { + var map = new WorldMap(); + map.Add(new MapRoom { Id = 1, Name = "Hall", Exits = { ["east"] = 2 }, Stubs = { "south" } }); + map.Add(new MapRoom { Id = 2, Name = "Yard", X = 1, Exits = { ["west"] = 1 } }); + var ink = new MapRenderer.Ink("#ffffff", "#00ff00", "#ffff00", "#ff00ff"); + + var lines = MapRenderer.Render(map, 1, [2], 20, 5, ink); + var plain = lines.Select(MarkupText.Plain).ToList(); + + await Assert.That(plain.All(l => l.Length <= 20)).IsTrue(); + await Assert.That(plain).Contains(l => l.Contains("[@]─[ ]")); + await Assert.That(plain).Contains(l => l.Trim() == "│"); + await Assert.That(lines).Contains(l => l.Contains("[dim]│[/]")); + await Assert.That(lines).Contains(l => l.Contains("[link=mux:map:2][#ffff00][[ ]][/][/]")); + await Assert.That(MapRenderer.ParseLink("mux:map:2")).IsEqualTo(2L); + await Assert.That(MapRenderer.ParseLink("mux:send:2")).IsNull(); + } + + [Test] + [Arguments("Hall", null)] + [Arguments("Hall", "Keep")] + [Arguments("The Exceedingly Long Hall of the Ancient Kings", "The Northern Reaches")] + public async Task TheStatusRoomCellKeepsItsWidth(string name, string? area) + { + await Assert.That(MarkupText.VisibleLength(SharpMUTermApp.RoomCell(name, area))).IsEqualTo(SharpMUTermApp.RoomCellWidth); + } + + [Test] + public async Task TheStatusRowSaysWhereYouAre() + { + var clock = new ManualTimeProvider(); + var (app, _) = await Open(clock); + await using var _app = app; + clock.Advance(SharpMUTermApp.NoticeDuration + TimeSpan.FromSeconds(1)); // the switch's notice holds the row + app.RenderNextFrame(); + + await Assert.That(MarkupText.Plain(app.StatusMarkup)).Contains("⌂ Hall · Keep"); + } + + private static void Type(SharpMUTermApp app, string text) + { + foreach (var c in text) + { + app.SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + + app.SimulateKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/MooEditTests.cs b/tests/SharpMUTerm.Tui.Tests/MooEditTests.cs new file mode 100644 index 00000000..4eb9759b --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/MooEditTests.cs @@ -0,0 +1,133 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Telnet; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// A MOO's edit, end to end: the buffer opens in the composer, ⌃S saves it back as lines, an edit that +/// lands over something else waits for F1, and a failed save keeps it. +/// +[NotInParallel] +public class MooEditTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static readonly MooEdit Look = new("#98.look", "Test:look", "moo-code", ["player:tell(\"Hi.\");", "return 1;"]); + + private static ConsoleKeyInfo Key(ConsoleKey key, ConsoleModifiers modifiers = 0) => + new('\0', key, modifiers.HasFlag(ConsoleModifiers.Shift), + modifiers.HasFlag(ConsoleModifiers.Alt), modifiers.HasFlag(ConsoleModifiers.Control)); + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Telnet, string Key)> Connected() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Moo", Host = "moo.example.org", Port = 7777 }; + world.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() }); + config.Worlds.Add(world); + + var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 32)); + var telnet = new RecordingTelnetSession(); + app.TelnetFactory = _ => telnet; + var session = app.BindWorldWithoutConnecting(world); + await session.ConnectAsync(); + app.RenderNextFrame(); + return (app, telnet, session.SessionKey); + } + + [Test] + public async Task AnEditOpensInTheComposerAndCtrlSSavesItsLines() + { + var (app, telnet, key) = await Connected(); + telnet.ReceiveMooEdit(Look); + + await Assert.That(app.Composer.IsOpen).IsTrue(); + await Assert.That(app.Composer.Edit).IsEqualTo(Look); + await Assert.That(app.Composer.Body).IsEqualTo("player:tell(\"Hi.\");\nreturn 1;"); + await Assert.That(app.Composer.HeaderMarkup).Contains("EDIT"); + await Assert.That(app.Composer.HeaderMarkup).Contains("Test:look"); + + app.Composer.SimulateTyping("\n// 100% [sure]; done\n"); + await Assert.That(app.Composer.SimulateKey(Key(ConsoleKey.S, ConsoleModifiers.Control))).IsTrue(); + + await Assert.That(app.Composer.IsOpen).IsFalse(); + var saved = telnet.SavedEdits.Single(); + await Assert.That(saved.Edit).IsEqualTo(Look); + + // As typed: no %r join and no escaping, and only the caret's last newline dropped. + await Assert.That(saved.Lines).IsEquivalentTo(new[] { "player:tell(\"Hi.\");", "return 1;", "// 100% [sure]; done" }); + await Assert.That(telnet.Lines).IsEmpty(); + await Assert.That(app.HeldMooEdit(key)).IsNull(); + } + + [Test] + public async Task AltLDoesNothingToAnEdit() + { + var (app, telnet, _) = await Connected(); + telnet.ReceiveMooEdit(Look); + var before = app.Composer.Escaping; + + app.Composer.SimulateKey(Key(ConsoleKey.L, ConsoleModifiers.Alt)); + + await Assert.That(app.Composer.Escaping).IsEqualTo(before); + await Assert.That(app.Composer.HeaderMarkup).DoesNotContain("literal"); + } + + [Test] + public async Task AnEditOverAnotherSurfaceWaitsForF1AndLeavesThePostDraftAlone() + { + var (app, telnet, key) = await Connected(); + app.SimulateKey(Key(ConsoleKey.F1)); + app.Composer.SimulateTyping("+bbpost 1=half written"); + app.Composer.SimulateKey(Key(ConsoleKey.Escape)); + await Assert.That(app.Composer.IsOpen).IsFalse(); + + app.SimulateKey(Key(ConsoleKey.F7)); + telnet.ReceiveMooEdit(Look); + await Assert.That(app.Composer.IsOpen).IsFalse(); + await Assert.That(app.HeldMooEdit(key)).IsEqualTo(Look); + await Assert.That(app.StatusMarkup).Contains("F1 in that window opens it"); + + app.SimulateKey(Key(ConsoleKey.F7)); + app.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(app.Composer.Edit).IsEqualTo(Look); + + // With the edit saved, F1 is the post again. + app.Composer.SimulateKey(Key(ConsoleKey.S, ConsoleModifiers.Control)); + app.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(app.Composer.Edit).IsNull(); + await Assert.That(app.Composer.Body).IsEqualTo("+bbpost 1=half written"); + } + + [Test] + public async Task AFailedSaveKeepsTheEdit() + { + var (app, telnet, key) = await Connected(); + telnet.RefusesEdits = true; + telnet.ReceiveMooEdit(Look); + app.Composer.SimulateTyping("\nchanged"); + + app.Composer.SimulateKey(Key(ConsoleKey.S, ConsoleModifiers.Control)); + + await Assert.That(app.Composer.IsOpen).IsFalse(); + await Assert.That(app.HeldMooEdit(key)).IsEqualTo(Look); + app.SimulateKey(Key(ConsoleKey.F1)); + await Assert.That(app.Composer.Body).EndsWith("changed"); + } + + [Test] + public async Task TheMoosStatusIsOnTheStatusRowInAReservedCell() + { + var (app, telnet, _) = await Connected(); + telnet.ReceiveMcpStatus("You are hungry."); + var hungry = app.StatusMarkup; + telnet.ReceiveMcpStatus("Sated."); + + await Assert.That(hungry).Contains("You are hungry."); + await Assert.That(app.StatusMarkup).Contains("Sated."); + await Assert.That(MarkupText.VisibleLength(app.StatusMarkup)).IsEqualTo(MarkupText.VisibleLength(hungry)); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/OggDecodeTests.cs b/tests/SharpMUTerm.Tui.Tests/OggDecodeTests.cs new file mode 100644 index 00000000..b6d78393 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/OggDecodeTests.cs @@ -0,0 +1,49 @@ +using SharpMUTerm.Audio; + +namespace SharpMUTerm.Tui.Tests; + +/// miniaudio reads no Vorbis, so an OGG is decoded to a WAV first. The device is not needed for that. +public class OggDecodeTests +{ + /// A tenth of a second of 440 Hz, mono, 8 kHz, encoded by oggenc. + private const string Tone = "T2dnUwACAAAAAAAAAACYZ7luAAAAALyuOjsBHgF2b3JiaXMAAAAAAUAfAAAAAAAAsDYAAAAAAACZAU9nZ1MAAAAAAAAAAAAAmGe5bgEAAAAcT77gC0T///////////+1A3ZvcmJpczQAAABYaXBoLk9yZyBsaWJWb3JiaXMgSSAyMDIwMDcwNCAoUmVkdWNpbmcgRW52aXJvbm1lbnQpAAAAAAEFdm9yYmlzEkJDVgEAAAEADFIUISUZU0pjCJVSUikFHWNQW0cdY9Q5RiFkEFOISRmle08qlVhKyBFSWClFHVNMU0mVUpYpRR1jFFNIIVPWMWWhcxRLhkkJJWxNrnQWS+iZY5YxRh1jzlpKnWPWMUUdY1JSSaFzGDpmJWQUOkbF6GJ8MDqVokIovsfeUukthYpbir3XGlPrLYQYS2nBCGFz7bXV3EpqxRhjjDHGxeJTKILQkFUAAAEAAEAEAUJDVgEACgAAwlAMRVGA0JBVAEAGAIAAFEVxFMdxHEeSJMsCQkNWAQBAAAACAAAojuEokiNJkmRZlmVZlqZ5lqi5qi/7ri7rru3qug6EhqwEAMgAABiGIYfeScyQU5BJJilVzDkIofUOOeUUZNJSxphijFHOkFMMMQUxhtAphRDUTjmlDCIIQ0idZM4gSz3o4GLnOBAasiIAiAIAAIxBjCHGkHMMSgYhco5JyCBEzjkpnZRMSiittJZJCS2V1iLnnJROSialtBZSy6SU1kIrBQAABDgAAARYCIWGrAgAogAAEIOQUkgpxJRiTjGHlFKOKceQUsw5xZhyjDHoIFTMMcgchEgpxRhzTjnmIGQMKuYchAwyAQAAAQ4AAAEWQqEhKwKAOAEAgyRpmqVpomhpmih6pqiqoiiqquV5pumZpqp6oqmqpqq6rqmqrmx5nml6pqiqnimqqqmqrmuqquuKqmrLpqvatumqtuzKsm67sqzbnqrKtqm6sm6qrm27smzrrizbuuR5quqZput6pum6quvasuq6su2ZpuuKqivbpuvKsuvKtq3Ksq5rpum6oqvarqm6su3Krm27sqz7puvqturKuq7Ksu7btq77sq0Lu+i6tq7Krq6rsqzrsi3rtmzbQsnzVNUzTdf1TNN1Vde1bdV1bVszTdc1XVeWRdV1ZdWVdV11ZVv3TNN1TVeVZdNVZVmVZd12ZVeXRde1bVWWfV11ZV+Xbd33ZVnXfdN1dVuVZdtXZVn3ZV33hVm3fd1TVVs3XVfXTdfVfVvXfWG2bd8XXVfXVdnWhVWWdd/WfWWYdZ0wuq6uq7bs66os676u68Yw67owrLpt/K6tC8Or68ax676u3L6Patu+8Oq2Mby6bhy7sBu/7fvGsamqbZuuq+umK+u6bOu+b+u6cYyuq+uqLPu66sq+b+u68Ou+Lwyj6+q6Ksu6sNqyr8u6Lgy7rhvDatvC7tq6cMyyLgy37yvHrwtD1baF4dV1o6vbxm8Lw9I3dr4AAIABBwCAABPKQKEhKwKAOAEABiEIFWMQKsYghBBSCiGkVDEGIWMOSsYclBBKSSGU0irGIGSOScgckxBKaKmU0EoopaVQSkuhlNZSai2m1FoMobQUSmmtlNJaaim21FJsFWMQMuekZI5JKKW0VkppKXNMSsagpA5CKqWk0kpJrWXOScmgo9I5SKmk0lJJqbVQSmuhlNZKSrGl0kptrcUaSmktpNJaSam11FJtrbVaI8YgZIxByZyTUkpJqZTSWuaclA46KpmDkkopqZWSUqyYk9JBKCWDjEpJpbWSSiuhlNZKSrGFUlprrdWYUks1lJJaSanFUEprrbUaUys1hVBSC6W0FkpprbVWa2ottlBCa6GkFksqMbUWY22txRhKaa2kElspqcUWW42ttVhTSzWWkmJsrdXYSi051lprSi3W0lKMrbWYW0y5xVhrDSW0FkpprZTSWkqtxdZaraGU1koqsZWSWmyt1dhajDWU0mIpKbWQSmyttVhbbDWmlmJssdVYUosxxlhzS7XVlFqLrbVYSys1xhhrbjXlUgAAwIADAECACWWg0JCVAEAUAABgDGOMQWgUcsw5KY1SzjknJXMOQggpZc5BCCGlzjkIpbTUOQehlJRCKSmlFFsoJaXWWiwAAKDAAQAgwAZNicUBCg1ZCQBEAQAgxijFGITGIKUYg9AYoxRjECqlGHMOQqUUY85ByBhzzkEpGWPOQSclhBBCKaWEEEIopZQCAAAKHAAAAmzQlFgcoNCQFQFAFAAAYAxiDDGGIHRSOikRhExKJ6WREloLKWWWSoolxsxaia3E2EgJrYXWMmslxtJiRq3EWGIqAADswAEA7MBCKDRkJQCQBwBAGKMUY845ZxBizDkIITQIMeYchBAqxpxzDkIIFWPOOQchhM455yCEEELnnHMQQgihgxBCCKWU0kEIIYRSSukghBBCKaV0EEIIoZRSCgAAKnAAAAiwUWRzgpGgQkNWAgB5AACAMUo5JyWlRinGIKQUW6MUYxBSaq1iDEJKrcVYMQYhpdZi7CCk1FqMtXYQUmotxlpDSq3FWGvOIaXWYqw119RajLXm3HtqLcZac865AADcBQcAsAMbRTYnGAkqNGQlAJAHAEAgpBRjjDmHlGKMMeecQ0oxxphzzinGGHPOOecUY4w555xzjDHnnHPOOcaYc84555xzzjnnoIOQOeecc9BB6JxzzjkIIXTOOecchBAKAAAqcAAACLBRZHOCkaBCQ1YCAOEAAIAxlFJKKaWUUkqoo5RSSimllFICIaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKaWUUkoppZRSSimllFJKKZVSSimllFJKKaWUUkoppQAg3woHAP8HG2dYSTorHA0uNGQlABAOAAAYwxiEjDknJaWGMQildE5KSSU1jEEopXMSUkopg9BaaqWk0lJKGYSUYgshlZRaCqW0VmspqbWUUigpxRpLSqml1jLnJKSSWkuttpg5B6Wk1lpqrcUQQkqxtdZSa7F1UlJJrbXWWm0tpJRaay3G1mJsJaWWWmupxdZaTKm1FltLLcbWYkutxdhiizHGGgsA4G5wAIBIsHGGlaSzwtHgQkNWAgAhAQAEMko555yDEEIIIVKKMeeggxBCCCFESjHmnIMQQgghhIwx5yCEEEIIoZSQMeYchBBCCCGEUjrnIIRQSgmllFJK5xyEEEIIpZRSSgkhhBBCKKWUUkopIYQQSimllFJKKSWEEEIopZRSSimlhBBCKKWUUkoppZQQQiillFJKKaWUEkIIoZRSSimllFJCCKWUUkoppZRSSighhFJKKaWUUkoJJZRSSimllFJKKSGUUkoppZRSSimlAACAAwcAgAAj6CSjyiJsNOHCAxAAAAACAAJMAIEBgoJRCAKEEQgAAAAAAAgA+AAASAqAiIho5gwOEBIUFhgaHB4gIiQAAAAAAAAAAAAAAAAET2dnUwAEIAMAAAAAAACYZ7luAgAAAI2GspIFEhEQEg6qm5mzlTewrwAAANBCC8349RSynJlzJ68AYAsAAAAAEFmXAK6cmW3lFQAoAAAAAICwUgCq05DcNW9gHQAAAFIuGpbX/gOGzJPgRFcLAADIjy/zAA=="; + + [Test] + public async Task AnOggBecomesAWavOfTheSameLengthAndShape() + { + var path = Path.Combine(Path.GetTempPath(), $"sharpmuterm-tone-{Guid.NewGuid():N}.ogg"); + File.WriteAllBytes(path, Convert.FromBase64String(Tone)); + try + { + using var wav = DesktopAudio.OggToWav(path); + var bytes = wav.ToArray(); + + await Assert.That(System.Text.Encoding.ASCII.GetString(bytes, 0, 4)).IsEqualTo("RIFF"); + await Assert.That(System.Text.Encoding.ASCII.GetString(bytes, 8, 4)).IsEqualTo("WAVE"); + await Assert.That(BitConverter.ToInt16(bytes, 22)).IsEqualTo((short)1); // channels + await Assert.That(BitConverter.ToInt32(bytes, 24)).IsEqualTo(8000); // rate + var samples = BitConverter.ToInt32(bytes, 40) / 2; + await Assert.That(samples).IsBetween(700, 900); + await Assert.That(bytes.Length).IsEqualTo(44 + samples * 2); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task AFileThatIsNotVorbisIsRefused() + { + var path = Path.Combine(Path.GetTempPath(), $"sharpmuterm-junk-{Guid.NewGuid():N}.ogg"); + File.WriteAllBytes(path, [1, 2, 3, 4, 5, 6, 7, 8]); + try + { + await Assert.That(() => DesktopAudio.OggToWav(path)).Throws(); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/PackagesAppTests.cs b/tests/SharpMUTerm.Tui.Tests/PackagesAppTests.cs new file mode 100644 index 00000000..9fb9261f --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PackagesAppTests.cs @@ -0,0 +1,343 @@ +using System.IO.Compression; +using System.Text; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Packages; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// Packages in the app: the review before an install, the Packages screen, and what each key writes. +[NotInParallel] +public class PackagesAppTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private const string Manifest = """ + {"id":"aether.basics","name":"Aether basics","version":"1.0.0","games":["aetherfall.example:4000"]} + """; + + private const string Set = """ + {"name":"Basics","triggers":[{"name":"hp","pattern":"^HP low","actions":{"sendResponse":"quaff"}}]} + """; + + private static byte[] Archive() + { + using var buffer = new MemoryStream(); + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, text) in new[] { ("package.json", Manifest), ("sets/basics.json", Set), ("scripts/basics.lua", "x = 1") }) + { + using var stream = zip.CreateEntry(name).Open(); + stream.Write(Encoding.UTF8.GetBytes(text)); + } + } + + return buffer.ToArray(); + } + + private sealed class Rig : IAsyncDisposable + { + public required SharpMUTermApp App { get; init; } + + public required AppConfiguration Config { get; init; } + + public required string Root { get; init; } + + public int Saves { get; set; } + + public async ValueTask DisposeAsync() + { + await App.DisposeAsync(); + Directory.Delete(Root, recursive: true); + } + } + + private static Rig Open() + { + Console.SetIn(TextReader.Null); + var root = Directory.CreateTempSubdirectory("sharpmuterm-pkgapp-").FullName; + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Aetherfall", + Host = "aetherfall.example", + Port = 4000, + Characters = { new CharacterDefinition { Name = "Corvid", Logging = new LoggingSettings() } }, + }); + + Rig? rig = null; + var app = new SharpMUTermApp( + config, + ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 32), null, null, null), + save: _ => rig!.Saves++, + packages: new PackageStore(Path.Combine(root, "packages"))); + rig = new Rig { App = app, Config = config, Root = root }; + return rig; + } + + private static void Type(SharpMUTermApp app, string text) + { + foreach (var c in text) + { + app.SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + + app.SimulateKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + } + + private static ConsoleKeyInfo Key(ConsoleKey key, char ch = '\0') => new(ch, key, false, false, false); + + [Test] + public async Task TheReviewSaysWhatTheArchiveAddsAndInstallsNothingUntilEnter() + { + await using var rig = Open(); + + rig.App.ReviewPackage(Archive()); + + await Assert.That(rig.App.PackageView.Mode).IsEqualTo("review"); + var text = string.Join("\n", rig.App.PackageView.Lines.Select(MarkupText.Plain)); + await Assert.That(text).Contains("1 of them sends commands as you"); + await Assert.That(text).Contains("basics.lua"); + await Assert.That(text).Contains("Corvid (Aetherfall)"); + await Assert.That(rig.App.Packages.Installed).IsEmpty(); + await Assert.That(Directory.Exists(Path.Combine(rig.Root, "packages"))).IsFalse(); + + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + + await Assert.That(rig.App.PackageView.IsOpen).IsFalse(); + await Assert.That(rig.App.Packages.Installed.Single().Id).IsEqualTo("aether.basics"); + await Assert.That(File.Exists(Path.Combine(rig.Root, "packages", "aether.basics", "package.json"))).IsTrue(); + await Assert.That(rig.Config.ResolveTriggerSets(rig.Config.Worlds[0].Characters[0], rig.Config.Worlds[0]).Select(s => s.Name)) + .Contains("Basics"); + } + + [Test] + public async Task EscapeOnTheReviewInstallsNothing() + { + await using var rig = Open(); + + rig.App.ReviewPackage(Archive()); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Escape, '\u001b')); + + await Assert.That(rig.App.PackageView.IsOpen).IsFalse(); + await Assert.That(rig.App.Packages.Installed).IsEmpty(); + await Assert.That(Directory.Exists(Path.Combine(rig.Root, "packages"))).IsFalse(); + } + + [Test] + public async Task ALinkIsFetchedThenReviewed() + { + await using var rig = Open(); + Uri? asked = null; + rig.App.PackageFetch = (uri, _) => { asked = uri; return Task.FromResult(Archive()); }; + + Type(rig.App, "/package install https://packages.example/basics.zip"); + for (var i = 0; i < 200 && !rig.App.PackageView.IsOpen; i++) + { + await Task.Delay(10); + } + + await Assert.That(asked!.Host).IsEqualTo("packages.example"); + await Assert.That(rig.App.PackageView.Mode).IsEqualTo("review"); + } + + [Test] + public async Task APlainHttpLinkIsRefused() + { + await using var rig = Open(); + rig.App.PackageFetch = (_, _) => throw new InvalidOperationException("must not fetch"); + + Type(rig.App, "/package install http://packages.example/basics.zip"); + + await Assert.That(rig.App.PackageView.IsOpen).IsFalse(); + await Assert.That(MarkupText.Plain(rig.App.StatusMarkup)).Contains("https only"); + } + + [Test] + public async Task SpaceSwitchesAPackageOffAndItsSetsStopApplying() + { + await using var rig = Open(); + rig.App.ReviewPackage(Archive()); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + var character = rig.Config.Worlds[0].Characters[0]; + + rig.App.SimulateKey(Key(ConsoleKey.F10)); + await Assert.That(rig.App.PackageView.Mode).IsEqualTo("list"); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Spacebar, ' ')); + + await Assert.That(rig.Config.DisabledPackages).Contains("aether.basics"); + await Assert.That(rig.Config.ResolveTriggerSets(character, rig.Config.Worlds[0]).Any(s => s.Name == "Basics")).IsFalse(); + await Assert.That(MarkupText.Plain(rig.App.PackageView.Lines[2])).Contains("[ ]"); + } + + [Test] + public async Task CopyMakesTheSetsThePlayersOwn() + { + await using var rig = Open(); + rig.App.ReviewPackage(Archive()); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + + rig.App.ShowPackages(); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.C, 'c')); + + var copy = rig.Config.TriggerSets.Single(s => s.Name == "Basics"); + await Assert.That(copy.Triggers.Single().Name).IsEqualTo("hp"); + await Assert.That(rig.App.PackageView.IsOpen).IsTrue(); + } + + [Test] + public async Task RemoveAsksOnceThenRemoves() + { + await using var rig = Open(); + rig.App.ReviewPackage(Archive()); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + rig.App.ShowPackages(); + + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Delete)); + await Assert.That(rig.App.Packages.Installed.Count).IsEqualTo(1); + await Assert.That(string.Join("\n", rig.App.PackageView.Lines.Select(MarkupText.Plain))).Contains("Del again removes it"); + + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Delete)); + + await Assert.That(rig.App.Packages.Installed).IsEmpty(); + await Assert.That(Directory.Exists(Path.Combine(rig.Root, "packages", "aether.basics"))).IsFalse(); + } + + [Test] + public async Task AnyOtherKeyKeepsAPackageAfterTheRemovePrompt() + { + await using var rig = Open(); + rig.App.ReviewPackage(Archive()); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + rig.App.ShowPackages(); + + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Delete)); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.DownArrow)); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Delete)); + + await Assert.That(rig.App.Packages.Installed.Count).IsEqualTo(1); + } + + [Test] + public async Task NotAPackageIsSaidSo() + { + await using var rig = Open(); + + rig.App.ReviewPackage([1, 2, 3]); + + await Assert.That(rig.App.PackageView.IsOpen).IsFalse(); + await Assert.That(MarkupText.Plain(rig.App.StatusMarkup)).Contains("not installed"); + } + + [Test] + public async Task ThePackagesAreOnTheCommandSurface() + { + await using var rig = Open(); + + var ids = rig.App.BuildCatalog().Select(i => i.Id).ToList(); + + await Assert.That(ids).Contains("term:packages"); + await Assert.That(ids).Contains("term:package-install"); + } + + [Test] + public async Task AnAppWithNoStoreWritesNothing() + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + await using var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 32)); + + app.ReviewPackage(Archive()); + app.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); + + await Assert.That(app.Packages.Installed.Count).IsEqualTo(1); + await Assert.That(app.Packages.HasRoot).IsFalse(); + } + + private static string IndexFor(byte[] archive, string? sha = null) => + $$""" + {"packages":[ + {"id":"other.thing","name":"Other thing","version":"0.1.0","games":["elsewhere.example"], + "url":"https://packages.example/other.zip","sha256":"{{new string('b', 64)}}"}, + {"id":"aether.basics","name":"Aether basics","version":"1.0.0","games":["aetherfall.example:4000"], + "url":"https://packages.example/basics.zip", + "sha256":"{{sha ?? Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(archive))}}"}]} + """; + + private static async Task Until(Func condition) + { + for (var i = 0; i < 300 && !condition(); i++) + { + await Task.Delay(10); + } + } + + [Test] + public async Task BrowseFetchesTheIndexOnlyWhenOpenedThenReviewsAVerifiedPackage() + { + await using var rig = Open(); + var asked = new List(); + var archive = Archive(); + rig.App.PackageFetch = (uri, _) => + { + lock (asked) + { + asked.Add(uri.AbsolutePath); + } + + return Task.FromResult(uri.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal) + ? Encoding.UTF8.GetBytes(IndexFor(archive)) + : archive); + }; + await Assert.That(asked).IsEmpty(); + + rig.App.DispatchCommand("term:package-browse"); + await Until(() => rig.App.PackageView.Mode == "browse"); + var text = string.Join("\n", rig.App.PackageView.Lines.Select(MarkupText.Plain)); + await Assert.That(text).Contains("Other thing"); + + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); // "Aether basics" sorts first + await Until(() => rig.App.PackageView.Mode == "review"); + + await Assert.That(asked).IsEquivalentTo(new[] { "/SharpMUSH/sharpmuterm-packages/main/index.json", "/basics.zip" }); + await Assert.That(rig.App.Packages.Installed).IsEmpty(); + } + + [Test] + public async Task AFocusedWorldsPackagesAreListedFirst() + { + await using var rig = Open(); + rig.App.TelnetFactory = _ => new RecordingTelnetSession(); + rig.App.DispatchCommand(SharpMUTerm.Core.Commands.CommandIds.Character("Aetherfall.Corvid")); + await rig.App.FindSession("Aetherfall.Corvid")!.ConnectAsync(); + var archive = Archive(); + rig.App.PackageFetch = (_, _) => Task.FromResult(Encoding.UTF8.GetBytes(IndexFor(archive))); + + rig.App.BrowsePackages(); + await Until(() => rig.App.PackageView.Mode == "browse"); + + var rows = rig.App.PackageView.Lines.Select(MarkupText.Plain).ToList(); + await Assert.That(rows[0]).Contains("Aetherfall's first"); + await Assert.That(rows.FindIndex(r => r.Contains("Aether basics"))).IsLessThan(rows.FindIndex(r => r.Contains("Other thing"))); + } + + [Test] + public async Task AnArchiveThatIsNotTheOneListedIsRefusedBeforeTheReview() + { + await using var rig = Open(); + var archive = Archive(); + rig.App.PackageFetch = (uri, _) => Task.FromResult(uri.AbsolutePath.EndsWith("index.json", StringComparison.Ordinal) + ? Encoding.UTF8.GetBytes(IndexFor(archive, sha: new string('c', 64))) + : archive); + + rig.App.BrowsePackages(); + await Until(() => rig.App.PackageView.Mode == "browse"); + rig.App.PackageView.SimulateKey(Key(ConsoleKey.Enter, '\r')); // "Aether basics" sorts first + await Until(() => MarkupText.Plain(rig.App.StatusMarkup).Contains("SHA-256")); + + await Assert.That(rig.App.PackageView.IsOpen).IsFalse(); + await Assert.That(MarkupText.Plain(rig.App.StatusMarkup)).Contains("does not match the index's SHA-256"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/PointerTests.cs b/tests/SharpMUTerm.Tui.Tests/PointerTests.cs index f1b9838a..7ba12e8b 100644 --- a/tests/SharpMUTerm.Tui.Tests/PointerTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PointerTests.cs @@ -27,6 +27,8 @@ private sealed class RecordingPresenter : IPointerPresenter public void ShowMenu(Point cell, IReadOnlyList items) => Menus.Add(items); + public bool DrawsMenus => true; + public void Scrollbars(IReadOnlyList bars) => Bars = bars; } diff --git a/tests/SharpMUTerm.Tui.Tests/PresenceTests.cs b/tests/SharpMUTerm.Tui.Tests/PresenceTests.cs new file mode 100644 index 00000000..1a02c266 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PresenceTests.cs @@ -0,0 +1,98 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Presence; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// Discord presence follows the window in front, only for a world that allows it, and is cleared otherwise. +[NotInParallel] +public class PresenceTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private const string Info = """{"applicationid":"123456789012345678"}"""; + private const string Status = """{"details":"In the Grand Plaza","state":"Level 12","partysize":2,"partymax":4}"""; + + private sealed class FakePresence : IPresence + { + public List Shown { get; } = []; + + public int Opened { get; set; } + + public void Show(GamePresence? presence) => Shown.Add(presence); + + public void Dispose() + { + } + } + + private static async Task<(SharpMUTermApp App, FakePresence Presence, Dictionary Wires)> Open(bool allow) + { + Console.SetIn(TextReader.Null); + var config = new AppConfiguration(); + var game = new WorldDefinition { Name = "Game", Host = "game.example", Port = 23, DiscordPresence = allow }; + game.Characters.Add(new CharacterDefinition { Name = "Ann", Logging = new() }); + var other = new WorldDefinition { Name = "Other", Host = "other.example", Port = 23 }; + other.Characters.Add(new CharacterDefinition { Name = "Bob", Logging = new() }); + config.Worlds.Add(game); + config.Worlds.Add(other); + + var presence = new FakePresence(); + var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 32), null, null, null) with + { + Presence = () => { presence.Opened++; return presence; }, + }; + var app = new SharpMUTermApp(config, host); + var wires = new Dictionary(); + app.TelnetFactory = options => wires[options.Host] = new RecordingTelnetSession(); + foreach (var key in new[] { "Other.Bob", "Game.Ann" }) + { + app.DispatchCommand(CommandIds.Character(key)); + await app.FindSession(key)!.ConnectAsync(); + } + + return (app, presence, wires); + } + + [Test] + public async Task TheGameInFrontIsShownAndSwitchingAwayClearsIt() + { + var (app, presence, wires) = await Open(allow: true); + await using var _ = app; + + wires["game.example"].ReceiveGmcp("External.Discord.Info", Info); + wires["game.example"].ReceiveGmcp("External.Discord.Status", Status); + app.RenderNextFrame(); + + await Assert.That(app.ShownPresence?.Details).IsEqualTo("In the Grand Plaza"); + await Assert.That(app.ShownPresence?.PartyMax).IsEqualTo(4); + await Assert.That(presence.Opened).IsEqualTo(1); + + app.DispatchCommand(CommandIds.Character("Other.Bob")); + app.RenderNextFrame(); + await Assert.That(app.ShownPresence).IsNull(); + await Assert.That(presence.Shown[^1]).IsNull(); + + app.DispatchCommand(CommandIds.Character("Game.Ann")); + await app.FindSession("Game.Ann")!.DisconnectAsync(); + app.RenderNextFrame(); + await Assert.That(app.ShownPresence).IsNull(); + } + + [Test] + public async Task AWorldThatHasNotOptedInNeverReachesDiscord() + { + var (app, presence, wires) = await Open(allow: false); + await using var _ = app; + + wires["game.example"].ReceiveGmcp("External.Discord.Info", Info); + wires["game.example"].ReceiveGmcp("External.Discord.Status", Status); + app.RenderNextFrame(); + + await Assert.That(presence.Opened).IsEqualTo(0); + await Assert.That(app.ShownPresence).IsNull(); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs index 6c192f0a..d7c1b875 100644 --- a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs +++ b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs @@ -59,13 +59,60 @@ public IReadOnlyList Lines public event EventHandler? OutputReceived; -#pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? GmcpReceived; public event EventHandler? MsdpReceived; + +#pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? MsspReceived; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; #pragma warning restore CS0067 + public event EventHandler? MsdpEnabled; + + public void ReceiveGmcp(string package, string json) => + GmcpReceived?.Invoke(this, new GmcpMessageEventArgs(package, json)); + + public void ReceiveMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + /// Every GMCP message the client sent, oldest first. + public List<(string Package, string Json)> SentGmcp { get; } = new(); + + /// Every MSDP variable the client sent, oldest first. + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + + public event EventHandler? MooEditReceived; + + public event EventHandler? McpStatusReceived; + + /// Hands the client a buffer to edit, as an authenticated simpleedit message would. + public void ReceiveMooEdit(MooEdit edit) => MooEditReceived?.Invoke(this, edit); + + public void ReceiveMcpStatus(string text) => McpStatusReceived?.Invoke(this, text); + + /// When set, a save is refused as a server that never agreed simpleedit would refuse it. + public bool RefusesEdits { get; set; } + + /// Every edit saved back, oldest first. + public List<(MooEdit Edit, IReadOnlyList Lines)> SavedEdits { get; } = new(); + + public ValueTask SendMooEditAsync(MooEdit edit, IReadOnlyList lines) + { + if (RefusesEdits) + { + return ValueTask.FromException(new InvalidOperationException("no MCP")); + } + + lock (SavedEdits) + { + SavedEdits.Add((edit, lines)); + } + + return ValueTask.CompletedTask; + } + public event EventHandler? EncodingChanged; /// Announces whatever has been set to, as a live session would. @@ -109,8 +156,25 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) => - ValueTask.CompletedTask; + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + lock (SentGmcp) + { + SentGmcp.Add((package, json)); + } + + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + lock (SentMsdp) + { + SentMsdp.Add((variable, value)); + } + + return ValueTask.CompletedTask; + } public ValueTask SetWindowSizeAsync(int width, int height) { diff --git a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs index e5b8c964..4cc3c22e 100644 --- a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs @@ -364,15 +364,16 @@ public async Task Options_NavigableRowsSkipSectionHeadersAndSpacers() var screen = OptionsScreenRenderer.TextAnsiScreen(); var model = OptionsScreenRenderer.Model(screen); - // 18 display rows: 5 section headers + 4 spacers + 9 options. It was 7/4 before WHITESPACE and + // 21 display rows: 6 section headers + 5 spacers + 10 options. It was 7/4 before WHITESPACE and // its "tab width (spaces)" row, which brought a header and a spacer with it, 10/5 before // "detect links in output" joined the COLOUR section, 11/6 before ACTIVITY and its // "activity bar holds for (seconds)" row brought a header and a spacer of their own, and 14/7 // before "keep text legible" joined COLOUR beneath the row it is the other half of, and 15/8 - // before BACKGROUND and its "background transparency (%)" row. - await Assert.That(screen.Rows.Count).IsEqualTo(18); + // before BACKGROUND and its "background transparency (%)" row, and 18/9 before STATUS ROW and its + // "meters" row. + await Assert.That(screen.Rows.Count).IsEqualTo(21); await Assert.That(model.PaneCount).IsEqualTo(1); - await Assert.That(model.Sizes[0]).IsEqualTo(9); + await Assert.That(model.Sizes[0]).IsEqualTo(10); } /// @@ -511,7 +512,7 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatFolderAndRestoreSwitc var model = WorldsScreenRenderer.Model(worlds, Sets(), 0, 0); var row = model.RowAt(WorldsScreenRenderer.CharactersPane, 0); - await Assert.That(row.FieldCount).IsEqualTo(9); + await Assert.That(row.FieldCount).IsEqualTo(10); await Assert.That(row.FieldAt(WorldsScreenRenderer.CharacterNameField)!.Value.Get()).IsEqualTo("Kaz"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Get()).IsEqualTo("Html"); await Assert.That(row.FieldAt(WorldsScreenRenderer.LogDirectoryField)!.Value.Get()).IsEqualTo("/logs/kaz"); diff --git a/tests/SharpMUTerm.Tui.Tests/ScriptsEndToEndTests.cs b/tests/SharpMUTerm.Tui.Tests/ScriptsEndToEndTests.cs new file mode 100644 index 00000000..6cb5becb --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/ScriptsEndToEndTests.cs @@ -0,0 +1,188 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Lua from the client's side: a character whose trigger set names a file gets that file run, and a rule's +/// script field calls into it. The changelog said "sandboxed Lua scripting" from the first release and +/// nothing in the app ever built a script host, so these are asserted over a connected session rather than +/// against the host alone. +/// +[NotInParallel] +public class ScriptsEndToEndTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static AppConfiguration Config() + { + var set = new TriggerSet { Name = "Lua", ScriptFiles = { "main.lua" } }; + set.Triggers.Add(new Trigger + { + Name = "ping", + Pattern = "^ping$", + Actions = new TriggerActions { ScriptCallback = "on_ping" }, + }); + set.Macros.Add(new Macro { Name = "scripted", Key = "F12", ScriptCallback = "on_key" }); + + var config = new AppConfiguration(); + config.TriggerSets.Add(set); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings(), TriggerSets = { "Lua" } } }, + }); + return config; + } + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Wire)> Open(string? root) + { + Console.SetIn(TextReader.Null); + var wire = new RecordingTelnetSession(); + var app = new SharpMUTermApp(Config(), Headless, new HeadlessConsoleDriver(120, 32), scriptsRoot: root); + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(Ann))) + { + throw new InvalidOperationException("the app would not switch to Ann"); + } + + await app.FindSession(Ann)!.ConnectAsync(); + app.RenderNextFrame(); + return (app, wire); + } + + private static string Root(string lua) + { + var root = Directory.CreateTempSubdirectory("sharpmuterm-scripts-").FullName; + File.WriteAllText(Path.Combine(root, "main.lua"), lua); + return root; + } + + [Test] + public async Task AConfiguredTriggerRunsTheFunctionItNames() + { + var root = Root("function on_ping() world.send('pong') end"); + try + { + var (app, wire) = await Open(root); + await using var _ = app; + + wire.Receive("ping"); + + await Assert.That(wire.Lines).Contains("pong"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// An app nobody gave a scripts folder reads no file and runs nothing, whatever the sets say. + [Test] + public async Task AnAppWithNoScriptsRootRunsNothing() + { + var (app, wire) = await Open(root: null); + await using var _ = app; + + wire.Receive("ping"); + + await Assert.That(wire.Lines).DoesNotContain("pong"); + await Assert.That(app.BuildCatalog().Select(i => i.Id)).DoesNotContain("world:reload-scripts"); + } + + [Test] + public async Task ReloadScriptsReadsTheFileAgain() + { + var root = Root("function on_ping() world.send('v1') end"); + try + { + var (app, wire) = await Open(root); + await using var _ = app; + await Assert.That(app.BuildCatalog().Select(i => i.Id)).Contains("world:reload-scripts"); + + File.WriteAllText(Path.Combine(root, "main.lua"), "function on_ping() world.send('v2') end"); + await Assert.That(app.DispatchCommand("world:reload-scripts")).IsTrue(); + wire.Receive("ping"); + + await Assert.That(wire.Lines).Contains("v2"); + await Assert.That(wire.Lines).DoesNotContain("v1"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// A script that fails says so in the character's own window, where the reader is looking. + [Test] + public async Task AScriptErrorIsPrintedInTheSessionsWindow() + { + var root = Root("function on_ping() error('boom') end"); + try + { + var (app, wire) = await Open(root); + await using var _ = app; + + wire.Receive("ping"); + app.RenderNextFrame(); + + var lines = app.FindSession(Ann)!.Scrollback.Snapshot().Select(l => l.ToString()); + await Assert.That(string.Join("\n", lines)).Contains("boom"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// A macro that only names a function runs it from a real key press, through the key chain. + [Test] + public async Task AScriptOnlyMacroRunsItsFunctionFromAKeyPress() + { + var root = Root("function on_key() world.send('keyed') end"); + try + { + var (app, wire) = await Open(root); + await using var _ = app; + + app.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F12, shift: false, alt: false, control: false)); + + await Assert.That(wire.Lines).Contains("keyed"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// A reload that could not read its file says so, rather than reporting the file as loaded. + [Test] + public async Task AReloadThatLoadsNothingDoesNotSayItLoaded() + { + var root = Root("function on_ping() world.send('pong') end"); + try + { + var (app, _) = await Open(root); + await using var __ = app; + + File.Delete(Path.Combine(root, "main.lua")); + await Assert.That(app.DispatchCommand("world:reload-scripts")).IsTrue(); + + var texts = app.Messages.Entries.Select(m => m.Text).ToArray(); + await Assert.That(texts.Any(t => t.Contains("0 of 1 scripts loaded"))).IsTrue(); + await Assert.That(texts.Any(t => t.EndsWith(": 1 script loaded"))).IsFalse(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/SendGroupTests.cs b/tests/SharpMUTerm.Tui.Tests/SendGroupTests.cs new file mode 100644 index 00000000..5ed0e1d7 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SendGroupTests.cs @@ -0,0 +1,99 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// The send group: picked on ⌃P, marked on the rail, one line to each connected member once. +[NotInParallel] +public class SendGroupTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static AppConfiguration Config() + { + var config = new AppConfiguration(); + var world = new WorldDefinition { Name = "Mush", Host = "mush.example", Port = 4201 }; + foreach (var name in new[] { "Ann", "Bob", "Cy" }) + { + world.Characters.Add(new CharacterDefinition { Name = name, Logging = new() }); + } + + config.Worlds.Add(world); + return config; + } + + private static async Task<(SharpMUTermApp App, Dictionary Wires)> Open(params string[] connect) + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(Config(), Headless, new HeadlessConsoleDriver(120, 32)); + var wires = new Dictionary(); + var queue = new Queue(); + app.TelnetFactory = _ => + { + var wire = new RecordingTelnetSession(); + wires[queue.Dequeue()] = wire; + return wire; + }; + + foreach (var name in new[] { "Ann", "Bob", "Cy" }) + { + app.DispatchCommand(CommandIds.Character("Mush." + name)); + if (connect.Contains(name)) + { + queue.Enqueue(name); + await app.FindSession("Mush." + name)!.ConnectAsync(); + } + } + + return (app, wires); + } + + [Test] + public async Task TheGroupIsOfferedOnceTwoCharactersAreOpenAndEachMemberIsMarkedOnTheRail() + { + var (app, _) = await Open("Ann", "Bob"); + await using var _app = app; + + var titles = app.BuildCatalog().Select(i => i.Title).ToArray(); + await Assert.That(titles).Contains("Add Ann to the send group"); + await Assert.That(titles).DoesNotContain("Send the command line to the group"); + + app.DispatchCommand(CommandIds.GroupToggle("Mush.Ann")); + titles = app.BuildCatalog().Select(i => i.Title).ToArray(); + await Assert.That(titles).Contains("Remove Ann from the send group"); + await Assert.That(titles).Contains("Send the command line to the group"); + + var rail = app.RailLines.Select(MarkupText.Plain).ToArray(); + await Assert.That(rail.Single(l => l.Contains("Ann"))).Contains("◆"); + await Assert.That(rail.Single(l => l.Contains("Bob"))).DoesNotContain("◆"); + } + + [Test] + public async Task ALineGoesOnceToEachConnectedMemberAndNotToTheOthers() + { + var (app, wires) = await Open("Ann", "Bob"); + await using var _app = app; + foreach (var key in new[] { "Mush.Ann", "Mush.Bob", "Mush.Cy" }) + { + app.DispatchCommand(CommandIds.GroupToggle(key)); + } + + foreach (var c in "pose waves") + { + app.SimulateKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false)); + } + + app.DispatchCommand(CommandIds.GroupSend); + await Task.Delay(50); + + await Assert.That(wires["Ann"].Lines).IsEquivalentTo(new[] { "pose waves" }); + await Assert.That(wires["Bob"].Lines).IsEquivalentTo(new[] { "pose waves" }); + await Assert.That(wires.ContainsKey("Cy")).IsFalse(); + await Assert.That(app.ArmedInputText).IsEqualTo(string.Empty); + await Assert.That(app.Messages.Entries[^1].Text).Contains("not to Cy"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/SendMenuTests.cs b/tests/SharpMUTerm.Tui.Tests/SendMenuTests.cs new file mode 100644 index 00000000..f3410d75 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SendMenuTests.cs @@ -0,0 +1,123 @@ +using System.Drawing; +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Text; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// An MXP <SEND> menu: the payload round trip, the window's menu, and the terminal's chooser. +[NotInParallel] +public class SendMenuTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static readonly SpanInteraction Menu = SpanInteraction.Command("buy lantern") with + { + Choices = [new SendChoice("Buy it", "buy lantern"), new SendChoice("Haggle, | , %", "haggle over a, b | c %20")], + }; + + [Test] + public async Task AMenuSurvivesTheLinkRoundTripAndItsSeparatorsCannotBeForged() + { + var emitted = LinkPayload.For(Menu)!; + var delivered = LinkPayload.Delivered(emitted); + + await Assert.That(delivered.Action).IsEqualTo(LinkAction.Send); + await Assert.That(delivered.Target).IsEqualTo("buy lantern"); + await Assert.That(delivered.Choices!.Select(c => (c.Label, c.Command))).IsEquivalentTo( + Menu.Choices!.Select(c => (c.Label, c.Command)), TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(LinkPayload.Delivered(LinkPayload.For(Menu with { PromptOnly = true })!).Action).IsEqualTo(LinkAction.Prompt); + } + + [Test] + public async Task AMalformedMenuIsNoPayloadAtAll() + { + await Assert.That(LinkPayload.Parse(LinkPayload.MenuScheme + "no-comma").Action).IsEqualTo(LinkAction.Unknown); + } + + private static SharpMUTermApp Snapshot() + { + Console.SetIn(TextReader.Null); + var app = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(120, 32)); + app.RenderSnapshot("send-menu"); + return app; + } + + [Test] + public async Task ARightClickInATerminalOpensTheChooserAndADigitSendsThatCommand() + { + await using var app = Snapshot(); + + await Assert.That(app.SendMenu.IsOpen).IsTrue(); + var text = string.Join("\n", app.SendMenu.Lines.Select(MarkupText.Plain)); + await Assert.That(text).Contains("Buy it"); + await Assert.That(text).Contains("haggle over a brass lantern"); + } + + [Test] + public async Task TheWindowsMenuListsEveryCommandByItsLabel() + { + Console.SetIn(TextReader.Null); + var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 32), null, null, _ => { }) with { Pointer = new MenuPresenter() }; + await using var app = new SharpMUTermApp(DemoScene.Build(), host); + app.RenderSnapshot("send-menu"); + + // The view opens the terminal's chooser; this is about the menu a right-click gets in the window. + app.SendMenu.Close(); + app.RenderWholeFrame(); + var cell = app.MenuLinkCell(SharpMUTermApp.MainWindowId)!.Value; + var labels = app.PointerMenu(cell).Select(i => i.Label).ToArray(); + await Assert.That(labels).Contains("Buy it"); + await Assert.That(labels).Contains("Look at it"); + await Assert.That(labels).Contains("Haggle"); + await Assert.That(app.TargetAt(cell).Tooltip).Contains("right-click for 2 more"); + } + + [Test] + public async Task TheChooserSendsTheChosenCommandToTheWindowsOwnWorld() + { + Console.SetIn(TextReader.Null); + var config = new SharpMUTerm.Core.Configuration.AppConfiguration(); + config.Worlds.Add(new SharpMUTerm.Core.Configuration.WorldDefinition + { + Name = "Market", + Host = "market.example", + Port = 4000, + ContentFormat = SharpMUTerm.Core.Configuration.ContentFormat.Mxp, + Characters = { new SharpMUTerm.Core.Configuration.CharacterDefinition { Name = "Ann", Logging = new() } }, + }); + await using var app = new SharpMUTermApp(config, Headless, new HeadlessConsoleDriver(120, 32)); + var wire = new RecordingTelnetSession(); + app.TelnetFactory = _ => wire; + app.DispatchCommand(SharpMUTerm.Core.Commands.CommandIds.Character("Market.Ann")); + await app.FindSession("Market.Ann")!.ConnectAsync(); + wire.Receive("\x1b[1zOn the cloth: a lantern."); + app.RenderNextFrame(); + var window = app.ActiveWindowId(); + + app.SimulatePointer(app.MenuLinkCell(window)!.Value, MouseFlags.Button3Pressed); + await Assert.That(app.SendMenu.IsOpen).IsTrue(); + app.SendMenu.SimulateKey(new ConsoleKeyInfo('2', ConsoleKey.D2, false, false, false)); + + await Assert.That(app.SendMenu.IsOpen).IsFalse(); + await Assert.That(wire.Lines).Contains("look a lantern"); + } + + private sealed class MenuPresenter : IPointerPresenter + { + public List> Menus { get; } = []; + + public void Hover(PointerTarget target) + { + } + + public void ShowMenu(Point cell, IReadOnlyList items) => Menus.Add(items); + + public bool DrawsMenus => true; + + public void Scrollbars(IReadOnlyList bars) + { + } + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs b/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs index 8b0139aa..d7abe0f7 100644 --- a/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs @@ -441,6 +441,7 @@ public async Task TheCharacterRowStepsFromItsNameThroughToItsLog() WorldsScreenRenderer.PasswordField, WorldsScreenRenderer.ConnectStringField, WorldsScreenRenderer.OnConnectField, + WorldsScreenRenderer.OnDisconnectField, // `at start` sits between the on-connect line and the log values, in the drawn order — the // walk is asserted in full so a field inserted without being drawn there would fail here. diff --git a/tests/SharpMUTerm.Tui.Tests/SoundTests.cs b/tests/SharpMUTerm.Tui.Tests/SoundTests.cs new file mode 100644 index 00000000..6750a80c --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/SoundTests.cs @@ -0,0 +1,234 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// Game sound in the app: MCMP to the queue, the device opened on demand, captions, mute and F7. +[NotInParallel] +public class SoundTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private sealed class Rig : IAsyncDisposable + { + public required SharpMUTermApp App { get; init; } + + public required RecordingTelnetSession Wire { get; init; } + + public required RecordingAudio Audio { get; init; } + + public required string Folder { get; init; } + + public int Opened { get; set; } + + public async ValueTask DisposeAsync() + { + await App.DisposeAsync(); + Directory.Delete(Folder, recursive: true); + } + } + + private static async Task Open(bool device = true, Action? configure = null, ManualTimeProvider? clock = null) + { + Console.SetIn(TextReader.Null); + var folder = Directory.CreateTempSubdirectory("sharpmuterm-sound-").FullName; + File.WriteAllBytes(Path.Combine(folder, "bell.wav"), [0x52, 0x49, 0x46, 0x46]); + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() } }, + }); + configure?.Invoke(config); + + var audio = new RecordingAudio(); + var media = new MediaCache(); + media.LocalFolders.Add(folder); + Rig? rig = null; + var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(160, 32), null, null, null) with + { + Audio = device ? () => { rig!.Opened++; return audio; } : null, + }; + var wire = new RecordingTelnetSession(); + var app = new SharpMUTermApp(config, host, time: clock, media: media); + rig = new Rig { App = app, Wire = wire, Audio = audio, Folder = folder }; + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(Ann))) + { + throw new InvalidOperationException("the app would not switch to Ann"); + } + + await app.FindSession(Ann)!.ConnectAsync(); + return rig; + } + + private static async Task Until(Func condition) + { + for (var i = 0; i < 200 && !condition(); i++) + { + await Task.Delay(10); + } + } + + private const string Bell = """{"name":"bell.wav","type":"sound","caption":"A bell rings"}"""; + + [Test] + public async Task APlayReachesTheDeviceAndTheDeviceOpensOnlyThen() + { + await using var rig = await Open(); + await Assert.That(rig.Opened).IsEqualTo(0); + + rig.Wire.ReceiveGmcp("Client.Media.Play", Bell); + await Until(() => rig.Audio.Played.Count > 0); + + await Assert.That(rig.Opened).IsEqualTo(1); + await Assert.That(rig.Audio.Played.Select(p => Path.GetFileName(p)!)).IsEquivalentTo(new[] { "bell.wav" }); + } + + [Test] + public async Task ASessionAdvertisesMediaOnlyWhereItCanPlayIt() + { + await using (var playing = await Open()) + { + await Assert.That(playing.App.FindSession(Ann)!.PlaysMedia).IsTrue(); + } + + await using (var silent = await Open(device: false)) + { + await Assert.That(silent.App.FindSession(Ann)!.PlaysMedia).IsFalse(); + } + + await using (var off = await Open(configure: c => c.Sound.Enabled = false)) + { + await Assert.That(off.App.FindSession(Ann)!.PlaysMedia).IsFalse(); + } + } + + [Test] + public async Task ACaptionIsALineInTheCharactersWindow() + { + await using var rig = await Open(configure: c => c.Sound.Captions = true); + + rig.Wire.ReceiveGmcp("Client.Media.Play", Bell); + await Until(() => rig.App.PaneLines(SharpMUTermApp.MainWindowId).Any(l => l.Contains("A bell rings"))); + + await Assert.That(rig.App.PaneLines(SharpMUTermApp.MainWindowId).Any(l => MarkupText.Plain(l).Contains("♪ A bell rings"))).IsTrue(); + } + + [Test] + public async Task WithNoDeviceACaptionIsStillShown() + { + await using var rig = await Open(device: false, configure: c => c.Sound.Captions = true); + + rig.Wire.ReceiveGmcp("Client.Media.Play", Bell); + + await Assert.That(rig.App.PaneLines(SharpMUTermApp.MainWindowId).Any(l => MarkupText.Plain(l).Contains("♪ A bell rings"))).IsTrue(); + } + + [Test] + public async Task MuteSilencesWhatIsPlayingAndSaysSoOnTheStatusRow() + { + var clock = new ManualTimeProvider(); + await using var rig = await Open(clock: clock); + rig.Wire.ReceiveGmcp("Client.Media.Play", Bell); + await Until(() => rig.Audio.Voices.Count > 0); + await Assert.That(rig.App.BuildCatalog().Any(i => i.Id == "term:mute")).IsTrue(); + + rig.App.DispatchCommand("term:mute"); + clock.Advance(SharpMUTermApp.NoticeDuration + TimeSpan.FromSeconds(1)); // the notice holds the row + rig.App.RenderNextFrame(); + + await Assert.That(rig.Audio.Voices[0].Volume).IsEqualTo(0f); + await Assert.That(rig.App.BuildCatalog().Any(i => i.Id == "term:unmute")).IsTrue(); + await Assert.That(MarkupText.Plain(rig.App.StatusMarkup)).Contains(SharpMUTermApp.MutedCell); + + rig.App.DispatchCommand("term:unmute"); + clock.Advance(SharpMUTermApp.NoticeDuration + TimeSpan.FromSeconds(1)); + rig.App.RenderNextFrame(); + + await Assert.That(rig.Audio.Voices[0].Volume).IsGreaterThan(0f); + await Assert.That(MarkupText.Plain(rig.App.StatusMarkup)).DoesNotContain(SharpMUTermApp.MutedCell); + } + + [Test] + public async Task NoMuteIsOfferedWhereNothingCanPlay() + { + await using var rig = await Open(device: false); + + await Assert.That(rig.App.BuildCatalog().Any(i => i.Id is "term:mute" or "term:unmute")).IsFalse(); + } + + [Test] + public async Task TheF7SoundRowsAreThere() + { + var sound = new SoundSettings(); + var labels = OptionsScreenRenderer.TextAnsiScreen(new TextSettings(), sound).Rows.Select(r => r.Label).ToList(); + + await Assert.That(labels).Contains("game sound"); + await Assert.That(labels).Contains("master volume (%)"); + await Assert.That(labels).Contains("sound captions"); + } + + [Test] + public async Task TheTestHarnessHasNoDevice() + { + var host = ClientHost.ForHarness(Headless, null, null, null, null); + + await Assert.That(host.Audio is null).IsTrue(); + } + + /// A device that records what it was asked to play. + private sealed class RecordingAudio : IAudioOutput + { + public List Played { get; } = new(); + + public List Voices { get; } = new(); + + public IAudioVoice? Play(string path, AudioStart start) + { + Played.Add(path); + var voice = new Voice(start.Volume); + Voices.Add(voice); + return voice; + } + + public void Dispose() + { + } + + internal sealed class Voice(float volume) : IAudioVoice + { + public float Volume { get; private set; } = volume; + + public event EventHandler? Ended + { + add { } + remove { } + } + + public void SetVolume(float value) => Volume = value; + + public void FadeTo(float value, TimeSpan over) => Volume = value; + + public void Pause() + { + } + + public void Resume() + { + } + + public void Dispose() + { + } + } + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TriggerSetManagementTests.cs b/tests/SharpMUTerm.Tui.Tests/TriggerSetManagementTests.cs index 71f9c47b..38019b66 100644 --- a/tests/SharpMUTerm.Tui.Tests/TriggerSetManagementTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/TriggerSetManagementTests.cs @@ -252,7 +252,7 @@ public async Task ASetsDescriptionIsTheRowsSecondField() var row = WorldsScreenRenderer.Model(Worlds(), sets, 0, 0) .RowAt(WorldsScreenRenderer.TriggerSetsPane, 0); - await Assert.That(row.FieldCount).IsEqualTo(2); + await Assert.That(row.FieldCount).IsEqualTo(3); await Assert.That(row.FieldAt(WorldsScreenRenderer.SetDescriptionField)!.Value.Get()) .IsEqualTo("channel routing"); @@ -261,6 +261,26 @@ await Assert.That(new ScreenEdits().Apply( await Assert.That(sets[0].Description).IsEqualTo("chat + pages"); } + /// + /// A set's script files are its third field: a comma-separated list, written back trimmed, without blanks + /// or repeats, in the order typed — the order they load in. + /// + [Test] + public async Task ASetsScriptsAreTheRowsThirdField() + { + var sets = Sets(); + var field = WorldsScreenRenderer.Model(Worlds(), sets, 0, 0) + .FieldAt(WorldsScreenRenderer.TriggerSetsPane, 0, WorldsScreenRenderer.SetScriptsField)!.Value; + + await Assert.That(string.IsNullOrEmpty(field.Get())).IsTrue(); + await Assert.That(new ScreenEdits().Apply(field, " lib.lua, comms.lua ,, lib.lua ")).IsNull(); + + await Assert.That(sets[0].ScriptFiles).IsEquivalentTo(new[] { "lib.lua", "comms.lua" }); + await Assert.That(field.Get()).IsEqualTo("lib.lua, comms.lua"); + await Assert.That(string.Join("\n", WorldsScreenRenderer.TriggersColumn( + Worlds()[0].Characters[0], sets, "#ffffff"))).Contains("lib.lua, comms.lua"); + } + // ---- deleting --------------------------------------------------------------------------------- /// diff --git a/tests/SharpMUTerm.Tui.Tests/WorldsScreenRendererTests.cs b/tests/SharpMUTerm.Tui.Tests/WorldsScreenRendererTests.cs index 732a475c..cd691c5a 100644 --- a/tests/SharpMUTerm.Tui.Tests/WorldsScreenRendererTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/WorldsScreenRendererTests.cs @@ -217,7 +217,8 @@ public async Task Render_TheCharacterListSelectsAndTheFormHoldsTheDetail() // None of the three restated columns survives in the list, nor the header that named them. foreach (var gone in new[] { "○ offline", "login", "manual", "trigger sets", "state" }) { - await Assert.That(column.Any(l => l.Contains(gone))).IsFalse().Because(gone + " is the form's"); + // The world's own `gmcp login` row is a world setting, not the character's login column. + await Assert.That(column.Any(l => l.Replace("gmcp login", string.Empty).Contains(gone))).IsFalse().Because(gone + " is the form's"); } // And each of them is still on the screen, in the form that owns it. diff --git a/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs b/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs new file mode 100644 index 00000000..ea37da59 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs @@ -0,0 +1,102 @@ +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// The settings that only config.json could change until F5 drew them: a world's content format and +/// emoji switches, and a character's on-disconnect commands. Each is edited through the screen's own field +/// and read back from the saved form. +/// +public class WorldsScreenSettingsTests +{ + private static List Worlds() => + [ + new() + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann" } }, + }, + ]; + + private static ScreenField WorldField(List worlds, int field) => + WorldsScreenRenderer.Model(worlds, [], 0, 0).FieldAt(WorldsScreenRenderer.WorldsPane, 0, field)!.Value; + + private static WorldDefinition RoundTrip(List worlds) + { + var config = new AppConfiguration(); + config.Worlds.AddRange(worlds); + return ConfigurationStore.Deserialize(ConfigurationStore.Serialize(config)).Worlds[0]; + } + + [Test] + [Arguments("auto", ContentFormat.Ansi)] + [Arguments("ansi", ContentFormat.AnsiOnly)] + [Arguments("MXP", ContentFormat.Mxp)] + [Arguments("pueblo", ContentFormat.Pueblo)] + public async Task ContentIsAChoiceOfFour(string typed, ContentFormat expected) + { + var worlds = Worlds(); + var field = WorldField(worlds, WorldsScreenRenderer.ContentField); + + await Assert.That(field.Get()).IsEqualTo("auto"); + await Assert.That(new ScreenEdits().Apply(field, typed)).IsNull(); + + await Assert.That(worlds[0].ContentFormat).IsEqualTo(expected); + await Assert.That(RoundTrip(worlds).ContentFormat).IsEqualTo(expected); + await Assert.That(new ScreenEdits().Apply(field, "html")).IsNotNull(); + } + + [Test] + [Arguments("off", false, true, true)] + [Arguments("emoticons", true, true, false)] + [Arguments("shortcodes", true, false, true)] + [Arguments("both", true, true, true)] + public async Task EmojiIsOneFieldOverThreeSwitches(string typed, bool enabled, bool emoticons, bool shortcodes) + { + var worlds = Worlds(); + var field = WorldField(worlds, WorldsScreenRenderer.EmojiField); + + await Assert.That(field.Get()).IsEqualTo("off"); + await Assert.That(new ScreenEdits().Apply(field, typed)).IsNull(); + + var saved = RoundTrip(worlds).Emoji; + await Assert.That((saved.Enabled, saved.Emoticons, saved.Shortcodes)).IsEqualTo((enabled, emoticons, shortcodes)); + await Assert.That(field.Get()).IsEqualTo(typed); + } + + [Test] + public async Task OnDisconnectSitsUnderOnConnect() + { + var worlds = Worlds(); + var model = WorldsScreenRenderer.Model(worlds, [], 0, 0); + var field = model.FieldAt(WorldsScreenRenderer.CharactersPane, 0, WorldsScreenRenderer.OnDisconnectField)!.Value; + + await Assert.That(field.Label).IsEqualTo("disconnect"); + await Assert.That(model.FieldAt(WorldsScreenRenderer.CharactersPane, 0, WorldsScreenRenderer.OnConnectField)!.Value.Label) + .IsEqualTo("on connect"); + await Assert.That(new ScreenEdits().Apply(field, "QUIT")).IsNull(); + + await Assert.That(RoundTrip(worlds).Characters[0].OnDisconnect).IsEqualTo("QUIT"); + } + + [Test] + public async Task TheDetailColumnShowsAllThree() + { + var worlds = Worlds(); + worlds[0].ContentFormat = ContentFormat.Mxp; + worlds[0].Emoji.Enabled = true; + worlds[0].Characters[0].OnDisconnect = "QUIT"; + + var text = string.Join("\n", WorldsScreenRenderer.DetailColumn(worlds, [], 0, 0, "#ffffff").Select(MarkupText.Plain)); + + await Assert.That(text).Contains("content"); + await Assert.That(text).Contains("mxp"); + await Assert.That(text).Contains("both"); + + var form = string.Join("\n", WorldsScreenRenderer.FormColumn(worlds[0].Characters[0], "#ffffff").Select(MarkupText.Plain)); + await Assert.That(form).Contains("disconnect QUIT"); + await Assert.That(form).Contains("QUIT"); + } +}