diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
new file mode 100644
index 0000000..0c50e27
--- /dev/null
+++ b/.github/workflows/docs.yaml
@@ -0,0 +1,78 @@
+name: Docs
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docsite/**'
+ - '.github/workflows/docs.yaml'
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ branches: [ main ]
+ paths:
+ - 'docsite/**'
+ - '.github/workflows/docs.yaml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.event_name == 'pull_request' && format('docs-pr-{0}', github.event.pull_request.number) || 'pages' }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: docsite
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Install pngquant
+ run: sudo apt-get update && sudo apt-get install -y pngquant
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version-file: "docsite/pyproject.toml"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+
+ - name: Install the project
+ run: uv sync --locked --all-extras --dev
+
+ - name: Build documentation
+ run: uv run zensical build -f zensical.toml --clean
+
+ - name: Upload artifact
+ if: github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: docsite/site
+
+ deploy:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/publish-preview.yaml b/.github/workflows/publish-preview.yaml
new file mode 100644
index 0000000..c00f3f2
--- /dev/null
+++ b/.github/workflows/publish-preview.yaml
@@ -0,0 +1,38 @@
+name: Publish Preview
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - 'docs/**'
+ - 'docsite/**'
+ - 'README.md'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/publish-preview.yml@v10.1
+ with:
+ solution: SharpMud.slnx
+ dotnetVersion: |
+ 10.0.x
+ 11.0.x
+ hasTests: true
+ prereleaseIdentifier: alpha
+ secrets: inherit
+
+ push:
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: LayeredCraft/devops-templates/.github/actions/nuget-push@v10.1
+ with:
+ nuget_user: ${{ secrets.NUGET_USER }}
diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml
new file mode 100644
index 0000000..888e8ac
--- /dev/null
+++ b/.github/workflows/publish-release.yaml
@@ -0,0 +1,30 @@
+name: Publish Release
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/publish-release.yml@v10.1
+ with:
+ solution: SharpMud.slnx
+ dotnetVersion: |
+ 10.0.x
+ 11.0.x
+ hasTests: true
+ secrets: inherit
+
+ push:
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: LayeredCraft/devops-templates/.github/actions/nuget-push@v10.1
+ with:
+ nuget_user: ${{ secrets.NUGET_USER }}
diff --git a/.gitignore b/.gitignore
index 956fed7..6531dc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -495,3 +495,7 @@ sharpmud.db
sharpmud.db-wal
sharpmud.db-shm
sharpmud.db-journal
+
+# docsite (Zensical/uv) build output and Python venv (uv.lock IS committed)
+docsite/site/
+docsite/.venv/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..cded8e0
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,54 @@
+
+
+
+ MIT
+ https://github.com/LayeredCraft/sharp-mud
+ git
+ Nick Cipollina
+ https://github.com/LayeredCraft/sharp-mud
+ icon.png
+ README.md
+ false
+ true
+ true
+ false
+ true
+
+ $(NoWarn);CS1591
+
+
+
+ embedded
+ true
+ latest
+ False
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4da05df..06f1efc 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,14 +5,26 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 57b212f..29c0c82 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,22 +1,24 @@
# Multi-stage build. Pinned to the same .NET 11 preview SDK/runtime version
# as global.json - see docs/architecture.md Open Items for the fallback-to-
# .NET-10-LTS plan if preview tooling support lags.
-FROM mcr.microsoft.com/dotnet/sdk:11.0.100-preview.5 AS build
+FROM mcr.microsoft.com/dotnet/sdk:11.0.100-preview.6 AS build
WORKDIR /src
-COPY Directory.Packages.props global.json ./
+COPY Directory.Build.props Directory.Packages.props global.json ./
COPY src/SharpMud.Engine/SharpMud.Engine.csproj src/SharpMud.Engine/
-COPY src/SharpMud.Ruleset.Classic/SharpMud.Ruleset.Classic.csproj src/SharpMud.Ruleset.Classic/
+COPY src/SharpMud.Hosting/SharpMud.Hosting.csproj src/SharpMud.Hosting/
COPY src/SharpMud.Persistence/SharpMud.Persistence.csproj src/SharpMud.Persistence/
+COPY src/SharpMud.Persistence.Sqlite/SharpMud.Persistence.Sqlite.csproj src/SharpMud.Persistence.Sqlite/
COPY src/SharpMud.Adapters.Cli/SharpMud.Adapters.Cli.csproj src/SharpMud.Adapters.Cli/
COPY src/SharpMud.Adapters.Telnet/SharpMud.Adapters.Telnet.csproj src/SharpMud.Adapters.Telnet/
-COPY src/SharpMud.Host/SharpMud.Host.csproj src/SharpMud.Host/
-RUN dotnet restore src/SharpMud.Host/SharpMud.Host.csproj
+COPY samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj samples/SharpMud.Samples.Classic/
+RUN dotnet restore samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj
COPY src/ src/
-RUN dotnet publish src/SharpMud.Host/SharpMud.Host.csproj -c Release -o /app --no-restore
+COPY samples/ samples/
+RUN dotnet publish samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj -c Release -o /app --no-restore
-FROM mcr.microsoft.com/dotnet/runtime:11.0.0-preview.5-alpine3.24 AS runtime
+FROM mcr.microsoft.com/dotnet/runtime:11.0.0-preview.6-alpine3.24 AS runtime
WORKDIR /app
COPY --from=build /app .
@@ -40,4 +42,4 @@ VOLUME /data
# needing the larger -extra image just for culture data we don't use.
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true
-ENTRYPOINT ["dotnet", "SharpMud.Host.dll"]
+ENTRYPOINT ["dotnet", "SharpMud.Samples.Classic.dll"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..46d3d3f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 LayeredCraft
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NOTICE.md b/NOTICE.md
new file mode 100644
index 0000000..1e95e12
--- /dev/null
+++ b/NOTICE.md
@@ -0,0 +1,21 @@
+# Notice
+
+sharp-mud is licensed under the [MIT License](LICENSE).
+
+## Design inspiration
+
+sharp-mud's engine design (the `Thing`/`Behavior` composition model, the
+generic event system, session/transport abstractions, and several other
+architectural decisions) is informed by a close reading of
+[WheelMUD](https://github.com/DavidRieman/WheelMUD)'s source code and
+architecture. See [docs/research/wheelmud-findings.md](docs/research/wheelmud-findings.md)
+for the full source-dive record of what was adopted, what was deliberately
+changed, and why.
+
+sharp-mud is a clean-room reimplementation, not a fork or derivative of
+WheelMUD's source — no WheelMUD code is included in this repository.
+WheelMUD itself is licensed under the
+[Microsoft Public License (MS-PL)](https://github.com/DavidRieman/WheelMUD/blob/main/src/LICENSE.txt),
+which does not apply to sharp-mud's own, independently-written code (see
+[ADR-0006](docs/adr/0006-nuget-package-distribution.md)'s License and
+naming section for the full reasoning).
diff --git a/README.md b/README.md
index 21541dd..18c73a3 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,12 @@
A modern C#/.NET reimagining of a classic MUD (Multi-User Dungeon) — faithful
to the genre's feel while built with current .NET architecture.
+> **Alpha — not yet stable.** Pre-1.0, no SemVer compatibility guarantees
+> between releases yet; APIs, package boundaries, and persisted-data shape can
+> all still change. Also currently pinned to a preview .NET 11 SDK (see
+> `global.json`) — accept that risk, or build against `net10.0` only until a
+> stable release lands. Use it, but expect breaking changes.
+
- **`SPEC.md`** — vision and high-level decisions (start here).
- **`docs/`** — detailed per-subsystem design docs. Start with
`docs/engine-vs-ruleset.md` for the entity model (`Thing`/`Behavior`
@@ -12,38 +18,54 @@ to the genre's feel while built with current .NET architecture.
## Solution layout
```
-SharpMud.sln
+SharpMud.slnx
src/
- SharpMud.Engine/ # Thing/Behavior, events, generic behaviors,
- # command pipeline, session, tick loop.
- # Zero deps on any ruleset.
- SharpMud.Ruleset.Classic/ # D&D-flavored ruleset: stats, combat, kill/flee.
- # References Engine only.
- SharpMud.Persistence/ # EF Core repositories
- SharpMud.Adapters.Cli/ # local stdin/stdout session adapter
- SharpMud.Adapters.Telnet/ # raw TCP session adapter + listener
- SharpMud.Host/ # composition root / entry point / hub world content
+ SharpMud.Engine/ # Thing/Behavior, events, generic behaviors,
+ # command pipeline, session, tick loop.
+ # Zero deps on any ruleset.
+ SharpMud.Hosting/ # generic-host composition helpers, ruleset-agnostic
+ SharpMud.Persistence/ # EF Core repositories, provider-agnostic
+ SharpMud.Persistence.Sqlite/ # SQLite provider
+ SharpMud.Persistence.DynamoDb/ # DynamoDB provider
+ SharpMud.Adapters.Cli/ # local stdin/stdout session adapter
+ SharpMud.Adapters.Telnet/ # raw TCP session adapter + listener
+ SharpMud/ # meta-package (Engine + Hosting + Persistence only - ADR-0007)
+ samples/
+ SharpMud.Samples.Classic/ # D&D-flavored sample ruleset + composition root
tests/
SharpMud.Engine.Tests/
- SharpMud.Ruleset.Classic.Tests/
+ SharpMud.Hosting.Tests/
SharpMud.Persistence.Tests/
- SharpMud.Host.Tests/
+ SharpMud.Adapters.Cli.Tests/
+ SharpMud.Adapters.Telnet.Tests/
+ SharpMud.Samples.Classic.Tests/
```
## Building & testing
```
-dotnet build SharpMud.sln
-dotnet test SharpMud.sln
+dotnet build SharpMud.slnx
+dotnet test SharpMud.slnx
```
## Running
```
-dotnet run --project src/SharpMud.Host # local single-player CLI
-dotnet run --project src/SharpMud.Host -- --telnet [port] # telnet server, default port 4000
+dotnet run --project samples/SharpMud.Samples.Classic # local single-player CLI
+dotnet run --project samples/SharpMud.Samples.Classic -- --telnet [port] # telnet server, default port 4000
```
+## Using SharpMud as a library
+
+`src/` publishes as NuGet packages (`SharpMud.Engine`, `SharpMud.Hosting`,
+`SharpMud.Persistence`(`.Sqlite`/`.DynamoDb`), `SharpMud.Adapters.Cli`/
+`.Telnet`), plus a `SharpMud` meta-package pulling in the engine-level core
+(`Engine`/`Hosting`/`Persistence`) — you still add a persistence provider and
+a transport explicitly, per [ADR-0007](docs/adr/0007-narrow-meta-package-scope.md).
+`samples/SharpMud.Samples.Classic` is a full reference consumer — start there
+to see how a ruleset composes against the packages. See
+[ADR-0006](docs/adr/0006-nuget-package-distribution.md) for the design.
+
## Containerized
```
diff --git a/SPEC.md b/SPEC.md
index 046ab3d..68d3720 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -1,5 +1,9 @@
# sharp-mud — Design Spec
+**Status: alpha, pre-1.0.** No SemVer compatibility guarantees between
+releases yet — APIs, package boundaries, and persisted-data shape can all
+still change.
+
A modern C# reimagining of a classic MUD. Faithful to the genre's feel (verb-first
commands, room-based navigation, persistent world) while using current .NET
architecture patterns instead of the C/C++ codebases (Diku/Circle/LP-family) that
@@ -22,8 +26,10 @@ persistent public MUD.
the prior art this is adapted from and
[docs/engine-vs-ruleset.md](docs/engine-vs-ruleset.md) for the concrete
design. The game we're actually building (classic D&D-flavored stats/combat,
- the hand-built hub) lives in a separate `SharpMud.Ruleset.Classic` project
- that consumes the engine the same way a third party's game would.
+ the hand-built hub) lives in `samples/SharpMud.Samples.Classic`, a reference
+ consumer of the published `SharpMud.*` NuGet packages
+ ([ADR-0006](docs/adr/0006-nuget-package-distribution.md)) that consumes the
+ engine the same way a third party's game would.
## Architecture
@@ -38,7 +44,7 @@ optionally a `LockableBehavior`). This is adapted directly from WheelMUD (see
[docs/research/wheelmud-findings.md](docs/research/wheelmud-findings.md)) and
is the mechanism that makes the engine/ruleset split real: `SharpMud.Engine`
ships generic, ruleset-agnostic behaviors (rooms, exits, containment,
-identity); `SharpMud.Ruleset.Classic` adds the D&D-flavored ones (stats,
+identity); `SharpMud.Samples.Classic` adds the D&D-flavored ones (stats,
combat, dice-roll character creation) purely by composing more `Behavior`s
onto the same `Thing`s — the engine never references ruleset types. Full
design in [docs/engine-vs-ruleset.md](docs/engine-vs-ruleset.md).
@@ -133,7 +139,7 @@ derivatives separate "instant" commands from round-based combat resolution.
3. **Inventory & items**: pick up/drop/wear/wield, carry weight or slots.
4. **Engine/ruleset split** (retrofit, done now rather than deferred): convert
the entity model built in phases 1–3 to `Thing`/`Behavior` composition and
- extract the D&D-specific stat/combat rules into `SharpMud.Ruleset.Classic`,
+ extract the D&D-specific stat/combat rules into `SharpMud.Samples.Classic`,
per [docs/engine-vs-ruleset.md](docs/engine-vs-ruleset.md). Doing this
before NPCs/networking/accounts land means those phases are built against
the real engine boundary instead of needing their own retrofit later.
diff --git a/SharpMud.slnx b/SharpMud.slnx
index b462193..3fd1837 100644
--- a/SharpMud.slnx
+++ b/SharpMud.slnx
@@ -13,15 +13,21 @@
-
+
-
+
+
+
+
+
+
+
-
+
-
+
diff --git a/docs/README.md b/docs/README.md
index 597dace..e181cea 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,10 +13,10 @@ implementation detail.
| [character.md](character.md) | Player entity, D&D-style attributes, Race/Class modifiers, derived stats |
| [commands.md](commands.md) | Command parser/registry, `ICommand` pipeline, aliases, error handling, movement walkthrough |
| [combat.md](combat.md) | Round-based combat model, `ICombatResolver`, tick-driven resolution, disconnect-mid-fight handling |
-| [persistence.md](persistence.md) | Repository interfaces, EF Core, provider strategy (SQLite now, Mongo/DynamoDB later) |
-| [networking.md](networking.md) | `ISession` transport abstraction, adapter plan (CLI now, Telnet/SSH/WebSocket later) |
+| [persistence.md](persistence.md) | Repository interfaces, EF Core, provider strategy (SQLite and DynamoDB now, via separate provider packages) |
+| [networking.md](networking.md) | `ISession` transport abstraction, adapter plan (CLI and Telnet now, SSH/WebSocket later) |
| [accounts-auth.md](accounts-auth.md) | Username/password login, identity fields on Player |
-| [deployment.md](deployment.md) | Dockerfile, container runtime configuration (`HostOptions`), verified-working notes, AWS/CI open items |
+| [deployment.md](deployment.md) | Dockerfile, container runtime configuration (`SharpMudHostOptions`), verified-working notes, AWS/CI open items |
| [research/wheelmud-findings.md](research/wheelmud-findings.md) | WheelMUD codebase review with code citations — the prior art behind engine-vs-ruleset.md |
| [adr/README.md](adr/README.md) | Architecture Decision Records — the numbered, permanent record of individual design decisions (numbering, status lifecycle, index) |
diff --git a/docs/accounts-auth.md b/docs/accounts-auth.md
index 2d0739c..7669160 100644
--- a/docs/accounts-auth.md
+++ b/docs/accounts-auth.md
@@ -18,7 +18,7 @@ simplest model that still has real auth. Classic small-MUD convention (no
"alts"); revisit if multi-character-per-login is ever actually wanted.
The local CLI stays login-free, per `SPEC.md` — `LoginFlow` is only used by
-`HostRunner`'s Telnet path.
+`TelnetTransportBackgroundService`'s Telnet path.
## Identity Model
@@ -38,9 +38,9 @@ has been deleted. `Thing.Name` (the display name shown to other players) is
set equal to `Username` at character creation — there's no separate
"character name" concept, matching the "no alts" simplification above.
-Password hashing: `PasswordHashing` (`src/SharpMud.Host/PasswordHashing.cs`)
+Password hashing: `PasswordHashing` (`src/SharpMud.Hosting/PasswordHashing.cs`)
wraps `Microsoft.Extensions.Identity.Core`'s `PasswordHasher` — PBKDF2
-with a random salt, versioned hash format. Lives in `SharpMud.Host`, not
+with a random salt, versioned hash format. Lives in `SharpMud.Hosting`, not
Engine — this is login-flow infrastructure, not game logic, and Engine
shouldn't pick up an Identity package dependency for something only the
networked login flow needs. `TUser` is unused by the default hasher's
@@ -60,8 +60,9 @@ whether that ever changes).
## Terminal Login Flow
-Implemented in `src/SharpMud.Host/LoginFlow.cs`, called from
-`HostRunner.HandleConnectionAsync` (replacing the old name-only placeholder):
+Implemented in `src/SharpMud.Hosting/LoginFlow.cs`, called from
+`TelnetTransportBackgroundService.HandleConnectionAsync` (replacing the old
+name-only placeholder):
1. `"Username: "`, read a line. Empty input disconnects.
2. Look up the username — first among currently-live/online players in
@@ -89,7 +90,8 @@ Implemented in `src/SharpMud.Host/LoginFlow.cs`, called from
anything but `y`) loops back to the username prompt. `y` → `"Password: "`
/ `"Confirm password: "`, both echo-suppressed; mismatch or empty →
`"Passwords didn't match."`, loop back to username. Match → hash, create
- the character via `HubWorldBuilder.CreatePlayer`, and
+ the character via `IPlayerFactory.CreatePlayer` (the sample's
+ `ClassicPlayerFactory` wraps `HubWorldBuilder.CreatePlayer`), and
`IThingRepository.SaveTreeAsync` it immediately (not waiting for the
eventual disconnect-triggered save) so a crash right after creation
doesn't lose the new login.
@@ -117,8 +119,8 @@ tradeoff this project already accepted for v1).
## Verified
-Live, over real TCP against `HostRunner`'s Telnet listener, not just unit
-tests: a brand-new username creates a character (confirmed the `IAC
+Live, over real TCP against `TelnetTransportBackgroundService`'s Telnet
+listener, not just unit tests: a brand-new username creates a character (confirmed the `IAC
WILL/WONT ECHO` bytes are actually sent, visible as raw bytes to a
non-compliant test client); 3 consecutive wrong-password attempts are all
rejected with the generic message and the flow loops back to the username
@@ -130,7 +132,7 @@ confirmed the password hash itself survives a real process restart against
the same SQLite file — not just an in-memory check — by creating an account,
restarting the server, and logging in again with the same credentials.
-`PasswordHashingTests` (`SharpMud.Host.Tests`) covers: correct password
+`PasswordHashingTests` (`SharpMud.Hosting.Tests`) covers: correct password
verifies, wrong password fails, and two hashes of the same password are
never byte-identical (confirms the random salt is actually being used, not
silently skipped).
diff --git a/docs/adr/0006-nuget-package-distribution.md b/docs/adr/0006-nuget-package-distribution.md
index d28bb78..4545ce1 100644
--- a/docs/adr/0006-nuget-package-distribution.md
+++ b/docs/adr/0006-nuget-package-distribution.md
@@ -745,6 +745,10 @@ own composition root the way today's `SharpMud.Host` does.
- [docs/research/wheelmud-findings.md](../research/wheelmud-findings.md) —
WheelMUD prior art and license citation
- [PLAN-0006](../plans/0006-nuget-package-distribution.md)
+- [ADR-0007](0007-narrow-meta-package-scope.md) — narrows this ADR's
+ meta-package package set (Engine + Hosting + Persistence only, not
+ persistence providers or transport adapters); the rest of this ADR is
+ unchanged
- `coding-standards.md`'s DI/composition section (corrected in this change)
- WheelMUD license:
(MS-PL — see License and naming above for why this doesn't constrain
diff --git a/docs/adr/0007-narrow-meta-package-scope.md b/docs/adr/0007-narrow-meta-package-scope.md
new file mode 100644
index 0000000..e5790ef
--- /dev/null
+++ b/docs/adr/0007-narrow-meta-package-scope.md
@@ -0,0 +1,98 @@
+# [ADR-0007] Narrow the `SharpMud` Meta-Package to Engine + Hosting + Persistence
+
+**Status:** Accepted
+
+**Date:** 2026-07-20
+
+**Decision Makers:** solo
+
+## Context
+
+[ADR-0006](0006-nuget-package-distribution.md) shipped `SharpMud` as a
+meta-package with `ProjectReference`s to *every* other `SharpMud.*` package —
+`Engine`, `Hosting`, `Persistence`, both persistence providers
+(`.Sqlite`/`.DynamoDb`), and both transport adapters (`Adapters.Cli`/
+`.Telnet`). That was a deliberate choice at the time ("no `PackageId`
+overrides are needed except on the new meta-package... `ProjectReference`s to
+everything above").
+
+In PR review of the docsite Getting Started page, this surfaced a real
+problem: a consumer running `dotnet add package SharpMud` already gets both
+persistence providers and both transport adapters transitively, making the
+docs' "you still pick a persistence provider and a transport explicitly"
+framing false, and defeating the ala-carte flexibility ADR-0006 itself named
+as a decision driver. Every consumer pulls in a DynamoDB SDK dependency and
+a Telnet listener even if they only ever use SQLite + CLI, which isn't what
+"quick start" should mean for a package whose whole pitch is choosing your
+own persistence/transport.
+
+## Decision Drivers
+
+- The meta-package should be a fast on-ramp for the *engine-level* pieces
+ every consumer needs (Engine, Hosting, provider-agnostic Persistence), not
+ a way to sidestep the ala-carte provider/transport choice ADR-0006 itself
+ motivated.
+- Docs (`getting-started.md`) already describe `SharpMud` as "you still pick
+ a persistence provider and a transport" — narrowing the package to match
+ is less churn than rewriting every doc/example to match the current
+ all-inclusive package.
+
+## Considered Options
+
+1. Keep `SharpMud` including every package (status quo from ADR-0006).
+2. Narrow `SharpMud` to `Engine` + `Hosting` + `Persistence` only — a
+ consumer still explicitly adds one persistence provider package and one
+ (or more) transport adapter package(s).
+
+## Decision Outcome
+
+Chosen option: **2 — narrow the meta-package.** `SharpMud`'s
+`ProjectReference`s become `SharpMud.Engine`, `SharpMud.Hosting`, and
+`SharpMud.Persistence` only. Persistence providers and transport adapters
+are always explicit `dotnet add package` calls, regardless of whether a
+consumer also referenced the meta-package.
+
+### Positive Consequences
+
+- A consumer never gets an unused DynamoDB SDK or Telnet listener dependency
+ by default.
+- `getting-started.md`'s existing "meta-package + explicit provider +
+ explicit transport" install steps are now literally accurate instead of
+ needing a rewrite.
+
+### Negative Consequences
+
+- One more explicit `dotnet add package` line for the common case (Engine +
+ Hosting + Persistence + one provider + one transport is now 5 packages
+ instead of 3) — accepted as the direct tradeoff for "no unused
+ dependencies by default."
+
+## Pros and Cons of the Options
+
+### Option 1 — keep including everything
+
+- Good, because it's the fewest packages to `dotnet add` for the common
+ case.
+- Bad, because it silently pulls in both a DynamoDB SDK dependency and a
+ Telnet listener for every consumer, regardless of which (if either) they
+ actually use.
+- Bad, because it contradicts the "ala-carte flexibility" driver ADR-0006
+ itself named, and made the getting-started docs describe a package
+ contract that wasn't real.
+
+### Option 2 — narrow to Engine + Hosting + Persistence (chosen)
+
+- Good, because it matches the docs' existing framing without a rewrite.
+- Good, because it keeps the meta-package genuinely opt-in beyond the
+ provider-agnostic core.
+- Bad, because the "smallest possible game" quick start needs one more
+ explicit package reference than before.
+
+## Links
+
+- [ADR-0006](0006-nuget-package-distribution.md) — this ADR narrows one
+ specific decision from ADR-0006 (the meta-package's package set); the
+ rest of ADR-0006 (granular packages, `SharpMud.Hosting`, the overall
+ distribution strategy) is unchanged and still in effect.
+- [getting-started.md](https://github.com/LayeredCraft/sharp-mud/blob/main/docsite/docs/getting-started.md) —
+ the install guidance this decision keeps accurate.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 6aaf01c..b2c6dd7 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -66,3 +66,4 @@ the mechanics: numbering, status, and the index.
| [0004](0004-session-state-machine-and-reconnect.md) | Session State Machine + Linkdead Reconnect | Accepted |
| [0005](0005-security-role-model-and-moderation-commands.md) | Security Role Model + Moderation Commands | Accepted |
| [0006](0006-nuget-package-distribution.md) | NuGet Package Distribution + Sample-Based Ruleset Extraction | Accepted |
+| [0007](0007-narrow-meta-package-scope.md) | Narrow the `SharpMud` Meta-Package to Engine + Hosting + Persistence | Accepted |
diff --git a/docs/architecture.md b/docs/architecture.md
index 5454af8..6b2e9f2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -10,33 +10,45 @@ tooling/library support proves too immature). Nullable reference types enabled,
implicit usings on, file-scoped namespaces throughout.
```
-sharp-mud.sln
+SharpMud.slnx
src/
- SharpMud.Engine/ # Thing/Behavior, event system, generic behaviors,
- # command pipeline, session abstraction, tick loop.
- # Zero deps on Adapters, Persistence, or any ruleset.
- SharpMud.Ruleset.Classic/ # D&D-flavored ruleset: stats, combat, kill/flee.
- # References Engine only - see engine-vs-ruleset.md.
- SharpMud.Persistence/ # EF Core DbContext + repository implementations.
- # References Engine (for domain types / repo interfaces).
- SharpMud.Adapters.Cli/ # local stdin/stdout ISession implementation.
- SharpMud.Adapters.Telnet/ # raw TCP ISession + listener - see networking.md.
- SharpMud.Adapters.Ssh/ # (later)
- SharpMud.Adapters.WebSocket/ # (later)
- SharpMud.Host/ # composition root: DI wiring (Microsoft.Extensions.
- # DependencyInjection), config, process entry point.
- # The only project allowed to know about a specific
- # ruleset, and owns the hand-built hub world content.
+ SharpMud.Engine/ # Thing/Behavior, event system, generic behaviors,
+ # command pipeline, session abstraction, tick loop.
+ # Zero deps on Adapters, Persistence, or any ruleset.
+ SharpMud.Hosting/ # generic-host composition helpers: WorldContext,
+ # IWorldBuilder, IPlayerFactory, SessionLoop/LoginFlow,
+ # AddSharpMud* extension methods. Ruleset-agnostic -
+ # samples/rulesets plug in via those extension points.
+ SharpMud.Persistence/ # EF Core DbContext + repository interfaces, provider-agnostic.
+ # References Engine (for domain types / repo interfaces).
+ SharpMud.Persistence.Sqlite/ # SQLite provider package: UseSqlite + IStorageInitializer.
+ SharpMud.Persistence.DynamoDb/ # DynamoDB provider package: UseDynamo (net10.0 only).
+ SharpMud.Adapters.Cli/ # local stdin/stdout ISession implementation.
+ SharpMud.Adapters.Telnet/ # raw TCP ISession + listener - see networking.md.
+ SharpMud.Adapters.Ssh/ # (later)
+ SharpMud.Adapters.WebSocket/ # (later)
+ SharpMud/ # meta-package: Engine + Hosting + Persistence only -
+ # provider/transport packages always explicit (ADR-0007).
+ samples/
+ SharpMud.Samples.Classic/ # D&D-flavored sample ruleset + composition root
+ # (Program.cs). References everything; the only
+ # place that knows about a specific ruleset and
+ # owns the hand-built hub world content.
tests/
- SharpMud.Engine.Tests/ # xUnit v3 + AutoFixture + NSubstitute + AwesomeAssertions
- SharpMud.Ruleset.Classic.Tests/
+ SharpMud.Engine.Tests/ # xUnit v3 + AutoFixture + NSubstitute + AwesomeAssertions
+ SharpMud.Hosting.Tests/
SharpMud.Persistence.Tests/
+ SharpMud.Adapters.Cli.Tests/
+ SharpMud.Adapters.Telnet.Tests/
+ SharpMud.Samples.Classic.Tests/
```
**Dependency direction (strict, enforced by project references):**
-`Ruleset.Classic → Engine`, `Adapters.* → Engine`, `Persistence → Engine`,
-`Host → everything`. Engine never references Adapters, Persistence, or any
-ruleset — see [engine-vs-ruleset.md](engine-vs-ruleset.md) for the full
+`Adapters.* → Hosting + Engine` (both are direct references, not purely
+transitive through Hosting), `Persistence.* → Persistence + Engine` (same —
+direct references to both, not just Persistence), `Samples.Classic →
+everything`. Engine never references Adapters, Persistence,
+or any ruleset — see [engine-vs-ruleset.md](engine-vs-ruleset.md) for the full
rationale (this is the actual mechanism behind the "engine, not just a game"
goal in `SPEC.md`, not just the transport/persistence swappability described
below). This is also what makes transports (see [networking.md](networking.md))
@@ -61,24 +73,27 @@ Single server-wide heartbeat (per SPEC.md) that calls `OnTickAsync` on every
registered `ITickable` — combat rounds in progress, NPC AI, regen. Player
commands (movement, look, chat) are NOT gated by the tick; they execute
immediately via the command pipeline (see [commands.md](commands.md)).
-`Host` now starts `GameLoop.RunAsync` as a background task alongside the
-session's read loop, since `CombatManager` (see [combat.md](combat.md)) is
-the first real `ITickable` consumer.
+`GameLoopHostedService` (in `SharpMud.Hosting`) runs `IGameLoop.RunAsync` as a
+`BackgroundService` alongside the session read loop, since `CombatManager`
+(see [combat.md](combat.md)) is the first real `ITickable` consumer.
## Dependency Injection
-`Microsoft.Extensions.DependencyInjection`, standard container. `Host` is the
-only project that composes the graph: registers the chosen `IPlayerRepository`/
-`IRoomRepository` implementations (see [persistence.md](persistence.md)),
-the chosen `ISession`-producing adapter (see [networking.md](networking.md)),
-and engine services (`IGameLoop`, `ICommandRegistry`, `ICombatResolver`, etc).
+`Microsoft.Extensions.DependencyInjection`, standard container, wired via the
+.NET generic host (`Microsoft.Extensions.Hosting`). `SharpMud.Hosting`
+provides the `AddSharpMud*` extension methods every project composes with;
+the sample composition root (`samples/SharpMud.Samples.Classic/Program.cs`)
+is the only place that assembles the full graph — the chosen persistence
+provider (see [persistence.md](persistence.md)), the chosen transport
+(see [networking.md](networking.md)), and engine services (`IGameLoop`,
+`ICommandRegistry`, `ICombatResolver`, etc).
## Testing & Observability
- **Unit tests** (xUnit v3 + AutoFixture + NSubstitute + AwesomeAssertions,
per the `dotnet-unit-testing-patterns` skill conventions) required for:
`ICommandParser`, each `ICommand` implementation, `ICombatResolver` (now in
- `SharpMud.Ruleset.Classic.Tests`), `Thing`/`BehaviorManager`/`ThingEvents`
+ `SharpMud.Samples.Classic.Tests`), `Thing`/`BehaviorManager`/`ThingEvents`
propagation, stat-derivation formulas. These are pure/deterministic enough
to test without a live session or tick loop — `ISession` and repositories
are mocked via NSubstitute.
diff --git a/docs/character.md b/docs/character.md
index 84ace47..2657912 100644
--- a/docs/character.md
+++ b/docs/character.md
@@ -6,7 +6,7 @@ and [persistence.md](persistence.md) for how a Player is stored.
**Superseded by [engine-vs-ruleset.md](engine-vs-ruleset.md)**: `Player` below
is no longer a dedicated class — it's a `Thing` composed from an engine-level
-`PlayerBehavior` (identity only) plus a `SharpMud.Ruleset.Classic`
+`PlayerBehavior` (identity only) plus a `SharpMud.Samples.Classic`
`StatsBehavior` (everything on this page). This doc still describes the
correct stat shape; engine-vs-ruleset.md describes which project owns which
piece and why.
diff --git a/docs/combat.md b/docs/combat.md
index e75d066..d6fbbe4 100644
--- a/docs/combat.md
+++ b/docs/combat.md
@@ -6,7 +6,7 @@ subsystem docs. See [character.md](character.md) for Player stats and
this system hooks into.
**Superseded by [engine-vs-ruleset.md](engine-vs-ruleset.md)**: everything on
-this page now lives in `SharpMud.Ruleset.Classic`, not `SharpMud.Engine` -
+this page now lives in `SharpMud.Samples.Classic`, not `SharpMud.Engine` -
combat is ruleset-specific by design (see the findings doc's §9). `ICombatant`
becomes `CombatantBehavior`, attached to whichever `Thing`s the ruleset wants
to be able to fight; `Player`/`Npc` references below mean "a `Thing` with the
diff --git a/docs/deployment.md b/docs/deployment.md
index 9b49380..3e22ac0 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -7,11 +7,12 @@ server this containerizes.
## Container
`Dockerfile` (repo root) is a multi-stage build: `mcr.microsoft.com/dotnet/sdk`
-restores/publishes `SharpMud.Host` (which pulls in every project it
-references — Engine, Ruleset.Classic, Persistence, both adapters), then the
-published output is copied into a much smaller `mcr.microsoft.com/dotnet/runtime`
-Alpine image. Both stages are pinned to the exact SDK/runtime version in
-`global.json` (`11.0.100-preview.5`) — confirmed to exist on MCR as of this
+restores/publishes `SharpMud.Samples.Classic` (which pulls in every project it
+references — Engine, Hosting, Persistence + Persistence.Sqlite, both
+adapters), then the published output is copied into a much smaller
+`mcr.microsoft.com/dotnet/runtime` Alpine image. Both stages are pinned to the
+exact SDK/runtime version in
+`global.json` (`11.0.100-preview.6`) — confirmed to exist on MCR as of this
writing; see [architecture.md](architecture.md) Open Items for the .NET 10
LTS fallback plan if that ever stops being true.
@@ -22,7 +23,9 @@ docker run -p 4000:4000 sharpmud
## Runtime Configuration
-`HostOptions.Parse` (`src/SharpMud.Host/HostOptions.cs`) resolves the run
+`SharpMudHostOptions.Parse` (`src/SharpMud.Hosting/SharpMudHostOptions.cs`)
+resolves the DB path from an env var; the sample's `Program.cs`
+(`samples/SharpMud.Samples.Classic/Program.cs`) separately resolves the run
mode from, in precedence order: CLI args, then environment variables, then a
default (CLI/local mode). This is what lets the same image work both as
`docker run sharpmud` (telnet, via the Dockerfile's `ENV` defaults) and
@@ -31,9 +34,9 @@ debugging inside the container) without rebuilding.
| Setting | CLI arg | Env var | Default |
|---|---|---|---|
-| Mode | `--telnet` (first positional arg) | `SHARPMUD_MODE=telnet` | CLI (local single-player) |
-| Telnet port | second positional arg after `--telnet` | `SHARPMUD_TELNET_PORT` | `4000` |
-| SQLite DB path | — | `SHARPMUD_DB_PATH` | `./sharpmud.db` (`/data/sharpmud.db` in the container image) |
+| Mode | `--telnet` (anywhere in args) | `SHARPMUD_MODE=telnet` | CLI (local single-player) |
+| Telnet port | `--telnet ` (looked up by index, not positional - can combine with `--db-path` in either order) | `SHARPMUD_TELNET_PORT` | `4000` |
+| SQLite DB path | `--db-path ` | `SHARPMUD_DB_PATH` | `./sharpmud.db` (`/data/sharpmud.db` in the container image) |
The Dockerfile sets `SHARPMUD_MODE=telnet`, `SHARPMUD_TELNET_PORT=4000`, and
`SHARPMUD_DB_PATH=/data/sharpmud.db` as image defaults, plus `EXPOSE 4000`
@@ -46,9 +49,10 @@ discarded along with the container itself on `docker rm`/replace (a restart
of the *same* container without removal would still happen to work, but that
is not the real redeploy scenario and shouldn't be relied on).
-Graceful shutdown (both `SIGINT` and `SIGTERM`, via `PosixSignalRegistration`
-— see [persistence.md](persistence.md)'s Write Frequency section for why
-`Console.CancelKeyPress` was wrong for this) is what makes `docker stop`
+Graceful shutdown (both `SIGINT` and `SIGTERM`, via the .NET generic host's
+own shutdown sequence — see [persistence.md](persistence.md)'s Write
+Frequency section for why `Console.CancelKeyPress` was wrong for this) is
+what makes `docker stop`
actually save state before the container exits; see Verified below for a
real `docker stop` → container removed → fresh container from the same
image → data still there test.
diff --git a/docs/engine-vs-ruleset.md b/docs/engine-vs-ruleset.md
index 3e61cfc..819ce5e 100644
--- a/docs/engine-vs-ruleset.md
+++ b/docs/engine-vs-ruleset.md
@@ -28,23 +28,31 @@ src/
(Room/Area/Exit/Lockable/Player-identity/Npc-marker/
Wearable/Container), command pipeline, session
abstraction, tick loop. Zero ruleset knowledge.
- SharpMud.Ruleset.Classic/ D&D-flavored ruleset: Race/CharacterClass, stats,
- CombatantBehavior, combat resolver, kill/flee
- commands, dice-roll character creation.
+ SharpMud.Hosting/ Generic-host composition helpers (WorldContext,
+ IWorldBuilder, IPlayerFactory, SessionLoop/LoginFlow,
+ AddSharpMud* extensions). Ruleset-agnostic.
+ SharpMud.Persistence/ EF Core repositories, provider-agnostic.
References Engine only.
- SharpMud.Persistence/ EF Core repositories. References Engine only.
- SharpMud.Adapters.Cli/ Local stdin/stdout ISession. References Engine only.
- SharpMud.Host/ Composition root. References everything,
- including the specific ruleset(s) it wants to run,
- and owns the hand-built hub world content.
+ SharpMud.Adapters.Cli/ Local stdin/stdout ISession. References Hosting.
+samples/
+ SharpMud.Samples.Classic/ D&D-flavored sample ruleset: Race/CharacterClass,
+ stats, CombatantBehavior, combat resolver, kill/flee
+ commands, dice-roll character creation, PLUS the
+ composition root (Program.cs) that references
+ everything and owns the hand-built hub world content.
```
-Dependency direction is stricter than before: `Ruleset.Classic → Engine` only
-(never the reverse). `Host` is the only project allowed to know about a
-specific ruleset — this is what makes "swap the ruleset" mean something. A
-different game would be `SharpMud.Ruleset.SciFi` (or whatever), referencing
-`SharpMud.Engine` the exact same way, with its own `Host`-equivalent wiring it
-in instead of `Ruleset.Classic`.
+Dependency direction is stricter than before: a ruleset like the sample
+depends on `Engine`/`Hosting` only (never the reverse). Nothing under `src/`
+is allowed to know about a specific ruleset — this is what makes "swap the
+ruleset" mean something. A different game would be its own sample/consumer
+project (e.g. `SharpMud.Samples.SciFi`), referencing `SharpMud.Engine`/
+`SharpMud.Hosting` the exact same way, with its own composition root wiring
+it in instead of `SharpMud.Samples.Classic`. See
+[ADR-0006](adr/0006-nuget-package-distribution.md)/
+[PLAN-0006](plans/0006-nuget-package-distribution.md) for how this split is
+now distributed as NuGet packages (`SharpMud.*`) plus a sample consumer,
+rather than a single solution with one hardcoded ruleset.
## `Thing` + `Behavior`
@@ -192,9 +200,9 @@ exit canceling a move, a full container canceling an item pickup).
engine-level, not ruleset-level, since it depends only on `NpcBehavior`/
`ExitBehavior` — no combat or stat system involved. First real validation
that the split holds for a whole feature, not just data classes:
- `SharpMud.Ruleset.Classic.Tests` never had to change for this to work.
+ `SharpMud.Samples.Classic.Tests` never had to change for this to work.
-## Ruleset-level behaviors (`SharpMud.Ruleset.Classic`)
+## Ruleset-level behaviors (`SharpMud.Samples.Classic`)
- `StatsBehavior` — the D&D-style attributes (`Strength`...`Charisma`),
`Race`, `CharacterClass`, `Level`, `Experience`, `MaxHitPoints`/
@@ -214,7 +222,7 @@ exit canceling a move, a full container canceling an item pickup).
`(Thing Actor, Thing CurrentRoom, ...)`. Commands that need ruleset data
(`AttackCommand` needing `CombatantBehavior`) do `ctx.Actor.FindBehavior<...>()`
and fail gracefully if absent — this is the actual mechanism that keeps
-`AttackCommand` in `Ruleset.Classic` rather than `Engine`: it's the first
+`AttackCommand` in the sample ruleset rather than `Engine`: it's the first
command to depend on a ruleset-specific behavior type.
Adopted from WheelMUD (see findings doc §3): a lightweight `CommandGuards`
@@ -257,10 +265,9 @@ revisit if guard logic keeps growing.
serialize `Thing`+`Behavior` graphs; the JSON shape isn't designed yet.
- No `AssemblyLoadContext`-based dynamic ruleset loading — see `SPEC.md`
Deferred/Open Items.
-- How an actual external consumer gets `SharpMud.Engine` at all (not just
- how this repo's own `Host`/`Ruleset.Classic` are split) is resolved by
- [ADR-0006](adr/0006-nuget-package-distribution.md), not yet implemented —
- see [PLAN-0006](plans/0006-nuget-package-distribution.md).
- `SharpMud.Ruleset.Classic` and this repo's own `Host` move to `samples/`
- under that plan; this doc's project-structure listing above still
- describes their current `src/` locations until that plan executes.
+- How an actual external consumer gets `SharpMud.Engine` at all is resolved
+ by [ADR-0006](adr/0006-nuget-package-distribution.md)/
+ [PLAN-0006](plans/0006-nuget-package-distribution.md): `SharpMud.*` NuGet
+ packages plus `SharpMud.Samples.Classic` as a reference consumer under
+ `samples/`. Implemented — this doc's project-structure listing above
+ reflects the current layout.
diff --git a/docs/networking.md b/docs/networking.md
index 572c3cc..846b2e4 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -30,7 +30,8 @@ terminal or just a queued line on stdout for v1.
1. **`SharpMud.Adapters.Cli`** ✅ — implements `ISession` over
`Console.In`/`Console.Out`. Single process, single local player, fast
- iteration. `Host` uses this when run with no arguments.
+ iteration. `AddSharpMudCliTransport` registers `CliTransportBackgroundService`,
+ used when the composition root selects CLI mode.
2. **`SharpMud.Adapters.Telnet`** ✅ — `TelnetSession` (raw TCP via
`TcpClient`/`NetworkStream`, line-based I/O) + `TelnetListener` (accepts
connections, yields one `ISession` per client via `IAsyncEnumerable`).
@@ -39,8 +40,10 @@ terminal or just a queued line on stdout for v1.
[ADR-0002](adr/0002-telnet-protocol-negotiation.md). **MCCP/MXP/TermType
still not negotiated** — deferred, see Open Items; WheelMUD's
`Server/Telnet/` (docs/research/wheelmud-findings.md) is the reference to
- consult when those are actually needed. `Host` uses this when run with
- `--telnet [port]` (default 4000).
+ consult when those are actually needed. `AddSharpMudTelnetTransport(port)`
+ registers `TelnetTransportBackgroundService`, used when the composition
+ root selects Telnet mode (the sample ruleset does this via `--telnet [port]`,
+ default 4000).
3. **`SharpMud.Adapters.Ssh`** (later) — secure terminal access.
4. **`SharpMud.Adapters.WebSocket`** (later) — browser play via xterm.js.
@@ -48,13 +51,13 @@ Adding a transport is additive — a new project implementing `ISession` — and
never requires changes to game logic, command parsing, or world state (see
[architecture.md](architecture.md) for the enforced dependency direction that
guarantees this). Confirmed in practice: the Telnet adapter required zero
-changes to `SharpMud.Engine` or `SharpMud.Ruleset.Classic`.
+changes to `SharpMud.Engine` or the sample ruleset.
## Multi-Session Host
-`Host`'s per-connection read-eval loop is `SessionLoop.RunAsync` (extracted
-from what used to be inline in `Program.cs`), shared by every transport. For
-Telnet, `HostRunner.RunTelnetAsync` accepts connections in a loop and spawns
+The per-connection read-eval loop is `SessionLoop.RunAsync` (in
+`SharpMud.Hosting`), shared by every transport. For Telnet,
+`TelnetTransportBackgroundService` accepts connections in a loop and spawns
one `SessionLoop.RunAsync` task per connection against the same shared
`World`/`IGameLoop`/`ICommandRegistry` — this is what makes concurrent players
actually see and interact with each other (confirmed via a live two-client
@@ -64,10 +67,11 @@ Each connection is wrapped in a try/catch so one bad session can't take down
the listener or other connections (same exception-isolation principle as
command execution, see [architecture.md](architecture.md)).
-New Telnet connections are prompted for a name (`"Name: "`) before a player
-`Thing` is created — this is a placeholder for real login (see
-[accounts-auth.md](accounts-auth.md)'s username/password login prompt),
-not auth.
+New Telnet connections go through `LoginFlow` (`SharpMud.Hosting`) — real
+username/password prompts, DB-backed lookup/creation via
+`IThingRepository.FindPlayerByUsernameAsync`, not a bare name prompt. See
+[accounts-auth.md](accounts-auth.md) for the full implemented flow (this is
+no longer a placeholder).
## Sequence: Player Disconnects Mid-Fight
@@ -76,7 +80,7 @@ role in the sequence:)
1. The transport adapter detects the underlying stream closed/EOF, calls
`ISession.DisconnectAsync`.
-2. `Host`'s session-loop catches this, fires a `PlayerDisconnectedEvent`
+2. `SessionLoop` catches this, fires a `PlayerDisconnectedEvent`
consumed by Engine's disconnect handler.
## Reconnect / Session Resumption ✅ (ADR-0004)
diff --git a/docs/persistence.md b/docs/persistence.md
index 33b0412..c413e68 100644
--- a/docs/persistence.md
+++ b/docs/persistence.md
@@ -5,14 +5,19 @@ subsystem docs. See [engine-vs-ruleset.md](engine-vs-ruleset.md) for `Thing`/
`Behavior`, the entities being persisted.
**Implemented and verified**: `IThingRepository` (`SharpMud.Engine.Core`) and
-its EF Core/SQLite implementation `ThingRepository` (`SharpMud.Persistence`)
-exist and are wired into `Host` — see Verified below for what was actually
+its EF Core implementation `ThingRepository` (`SharpMud.Persistence`, provider-
+agnostic) exist, with `SharpMud.Persistence.Sqlite`/`SharpMud.Persistence.DynamoDb`
+providing the actual `AddSharpMud*Persistence` DI registration + provider
+selection (`UseSqlite`/`UseDynamo`) — see Verified below for what was actually
exercised end-to-end, not just unit-tested.
## Strategy
-EF Core + SQLite, behind a single repository interface in `SharpMud.Engine`
-(`IThingRepository`), implemented in `SharpMud.Persistence`. Game logic
+EF Core, behind a single repository interface in `SharpMud.Engine`
+(`IThingRepository`), implemented once in `SharpMud.Persistence` against a
+provider-agnostic `GameDbContext`. Provider selection (SQLite today, DynamoDB
+via `SharpMud.Persistence.DynamoDb`) happens at the DI registration call site
+in the provider-specific package, not in `GameDbContext` itself. Game logic
depends only on the interface — never on EF Core, SQLite, or SQL directly
(see [architecture.md](architecture.md) for the enforced dependency
direction).
@@ -40,7 +45,7 @@ column serializing the whole behavior list. TPH gets real relational columns
per behavior type (queryable, no deserialize-to-filter), at the cost of every
`Behavior` subclass needing an EF Core mapping. Since `Behavior` subclasses
span two assemblies that must not reference each other the wrong way
-(`SharpMud.Ruleset.Classic` behaviors must not be known to `SharpMud.Engine`
+(sample/ruleset behaviors must not be known to `SharpMud.Engine`
or `SharpMud.Persistence`), mapping registration is itself split:
```csharp
@@ -51,16 +56,16 @@ public interface IBehaviorMappingContributor
```
`SharpMud.Persistence` maps Engine's own behavior types directly (it already
-references `SharpMud.Engine`); `SharpMud.Ruleset.Classic` provides
-`ClassicBehaviorMappingContributor`, which requires a new `Ruleset.Classic →
-Persistence` project reference (approved as part of this design — Persistence
-still never references back). `Host` registers both via DI
+references `SharpMud.Engine`); `SharpMud.Samples.Classic` provides
+`ClassicBehaviorMappingContributor`, which requires a `Samples.Classic →
+Persistence` project reference (Persistence still never references back). The
+composition root registers both via DI
(`IEnumerable` constructor injection into
`GameDbContext`). Adding a second ruleset later means writing one new
contributor class, not touching `Persistence`.
Each entity/behavior type gets its own `IEntityTypeConfiguration` class
-(`SharpMud.Persistence/Configurations/`, `SharpMud.Ruleset.Classic/Configurations/`)
+(`SharpMud.Persistence/Configurations/`, `SharpMud.Samples.Classic/Configurations/`)
rather than one large inline `OnModelCreating` — `GameDbContext` just calls
`modelBuilder.ApplyConfigurationsFromAssembly(...)` over its own assembly,
and each contributor does the same over its own; adding a new behavior type
@@ -140,12 +145,20 @@ publish) for this purpose, visible to `SharpMud.Persistence` via
## SQLite Path
-`SHARPMUD_DB_PATH` env var, following the same precedence pattern as
-`HostOptions` (`SHARPMUD_MODE`/`SHARPMUD_TELNET_PORT`) — CLI arg, then env
-var, then a default of `./sharpmud.db`. Deploying the Docker container with
-this pointed at a mounted volume is required for the container's data to
-actually survive a redeploy — not solved here, flagged in
-[deployment.md](deployment.md).
+`--db-path ` CLI arg, then `SHARPMUD_DB_PATH` env var, then a default
+of `./sharpmud.db` — same precedence pattern as the sample's transport
+selection (`--telnet`/`SHARPMUD_MODE`/`SHARPMUD_TELNET_PORT`), resolved in
+the sample's own `Program.cs`, not in `SharpMudHostOptions.Parse` itself
+(which only ever takes the already-resolved env dict — see
+[architecture.md](architecture.md)/[ADR-0006](adr/0006-nuget-package-distribution.md)
+for why CLI-arg resolution stays a consumer concern, not `SharpMud.Hosting`'s).
+`SqliteStorageInitializer`
+(`SharpMud.Persistence.Sqlite`, an `IStorageInitializer`) runs `EnsureCreatedAsync`
+before the world loader reads/writes anything, so the schema always exists
+first regardless of registration order across packages. Deploying the Docker
+container with `SHARPMUD_DB_PATH` pointed at a mounted volume is required for
+the container's data to actually survive a redeploy — not solved here,
+flagged in [deployment.md](deployment.md).
## Write Frequency (revised scope)
@@ -170,7 +183,11 @@ what `docker stop`/Kubernetes send on a graceful shutdown, i.e. the actual
scenario this whole design exists for. This was caught during live testing,
not code review: `CancelKeyPress` was also observed not firing reliably
without a TTY attached (relevant since a container has none), independent of
-the `SIGTERM` gap. See `src/SharpMud.Host/Program.cs`.
+the `SIGTERM` gap. Now handled by the .NET generic host's own shutdown
+sequence (`IHostApplicationLifetime`) rather than a hand-rolled
+`PosixSignalRegistration` — `ShutdownSaveHostedService`
+(`SharpMud.Hosting`) does the whole-world save in its `StopAsync`. See
+`samples/SharpMud.Samples.Classic/Program.cs`.
## Verified
@@ -192,7 +209,7 @@ suite).
Also: `ThingRepositoryTests` (`SharpMud.Persistence.Tests`) round-trips a
room+exit pair, a player with `StatsBehavior`/`CombatantBehavior`/a carried
-item (exercises the cross-assembly Ruleset.Classic mapping contributor), a
+item (exercises the cross-assembly Samples.Classic mapping contributor), a
locked exit with a required key (nullable `Thing` reference resolution),
`FindPlayerByUsernameAsync` (including that it correctly ignores non-player
`Thing`s with a matching name), and that a second `SaveTreeAsync` call for
diff --git a/docs/plans/0006-nuget-package-distribution.md b/docs/plans/0006-nuget-package-distribution.md
index 2ece284..27a9fe5 100644
--- a/docs/plans/0006-nuget-package-distribution.md
+++ b/docs/plans/0006-nuget-package-distribution.md
@@ -2,7 +2,7 @@
**Implements:** [ADR-0006](../adr/0006-nuget-package-distribution.md)
-**Status:** Not Started
+**Status:** Done
**Last updated:** 2026-07-20
@@ -40,9 +40,9 @@ bundled into this plan's "done").
### Repository reorganization
-- [ ] `git mv src/SharpMud.Ruleset.Classic samples/SharpMud.Samples.Classic`
+- [x] `git mv src/SharpMud.Ruleset.Classic samples/SharpMud.Samples.Classic`
(preserve history — this becomes the single consolidated project)
-- [ ] **Update the merged project's `.csproj`, not just its file
+- [x] **Update the merged project's `.csproj`, not just its file
contents** — caught in PR review: `SharpMud.Ruleset.Classic.csproj`
today is a plain class library (no `OutputType`, no `Serilog`/config/
adapter references) — none of what `Program.cs` needs once it moves
@@ -55,7 +55,7 @@ bundled into this plan's "done").
logging-provider choice, not something `Hosting` should hardcode).
Skipping this leaves a project with `Program.cs` in it that doesn't
build.
-- [ ] **Split `src/SharpMud.Host`'s files by whether they're
+- [x] **Split `src/SharpMud.Host`'s files by whether they're
ruleset-specific or genuinely generic — don't move all of it to
`samples/` as one block** (caught in PR review: an earlier version of
this task sent everything to `samples/`, which would strand
@@ -143,17 +143,17 @@ bundled into this plan's "done").
(the sample), referencing `SharpMud.Hosting` and whichever
transport package(s) it wants, not one project referencing another
app project.
-- [ ] Move `HubWorldBuilder` (and any other hand-built hub content) into
+- [x] Move `HubWorldBuilder` (and any other hand-built hub content) into
the consolidated project — it's sample content per ADR-0006, not
engine
-- [ ] `git mv src/SharpMud.Host/appsettings.json
+- [x] `git mv src/SharpMud.Host/appsettings.json
samples/SharpMud.Samples.Classic/appsettings.json`, and add the same
``
item (per ADR-0003) to the new sample's `.csproj` — caught in PR
review: `Program.cs` loads it with `optional: false`, so without
this the sample fails at startup, not just at some later config
lookup.
-- [ ] **Move each test project to follow its production code, not as one
+- [x] **Move each test project to follow its production code, not as one
block** — mirrors the split above, not the "everything to `samples/`"
version this task previously described:
- `git mv tests/SharpMud.Ruleset.Classic.Tests
@@ -202,7 +202,7 @@ bundled into this plan's "done").
in PR review) — but `HostOptionsTests`/`LoginFlowTests` specifically
need real edits, not just a `git mv`, because the signatures/types
they test are changing.
-- [ ] Rewrite `samples/SharpMud.Samples.Classic/Program.cs` against
+- [x] Rewrite `samples/SharpMud.Samples.Classic/Program.cs` against
`SharpMud.Hosting`'s builder — this is the concrete proof that the
~130 lines of generic plumbing identified in ADR-0006's Context
actually collapse to a few lines, in a single project alongside the
@@ -218,7 +218,7 @@ bundled into this plan's "done").
`Dockerfile` sets `SHARPMUD_MODE=telnet` by default and expects it to
work; needs a real test proving that default still starts the Telnet
transport post-refactor, not just an assumption.
-- [ ] **Update the actual `Dockerfile`, not just docs referencing it** —
+- [x] **Update the actual `Dockerfile`, not just docs referencing it** —
flagged independently in two rounds of PR review (self-review and
Codex): it currently `COPY`s/restores/publishes
`src/SharpMud.Host/SharpMud.Host.csproj` and
@@ -238,27 +238,27 @@ bundled into this plan's "done").
lines stay as-is — they're still consumed by the sample's
`Program.cs`, which per the `HostOptions` split above is exactly
where transport-mode selection now lives.
-- [ ] Update `docs/engine-vs-ruleset.md`'s project-structure listing and
+- [x] Update `docs/engine-vs-ruleset.md`'s project-structure listing and
`docs/deployment.md`'s Dockerfile references to the new paths
### `SharpMud.Hosting` (new project)
-- [ ] `src/SharpMud.Hosting/SharpMud.Hosting.csproj` — `PackageReference`s
+- [x] `src/SharpMud.Hosting/SharpMud.Hosting.csproj` — `PackageReference`s
to `Microsoft.Extensions.Hosting` **and**
`Microsoft.Extensions.Identity.Core` (needed by `PasswordHashing.cs`
— caught in PR review, an earlier version of this plan/ADR-0006's
package table said `Hosting` needed nothing beyond
`Microsoft.Extensions.Hosting`)
-- [ ] `SharpMudApplicationBuilder : IHostApplicationBuilder` — wraps
+- [x] `SharpMudApplicationBuilder : IHostApplicationBuilder` — wraps
`Host.CreateApplicationBuilder(args)`, delegates
`Services`/`Configuration`/`Environment`/`Logging`/`Metrics`/
`Properties`/`ConfigureContainer` straight through; static
`SharpMudApplication.CreateBuilder(args)` factory
-- [ ] `SharpMudApplication : IHost` — wraps the built `IHost`; `RunAsync`
+- [x] `SharpMudApplication : IHost` — wraps the built `IHost`; `RunAsync`
delegates to it directly (no custom middleware/invocation pipeline —
see ADR-0006's comparison to `minimal-lambda` for why that's
deliberately not needed here)
-- [ ] **No `SharpMudOptions` type** (removed per PR review — an earlier
+- [x] **No `SharpMudOptions` type** (removed per PR review — an earlier
draft had it duplicating `DbPath` with the trimmed `HostOptions`
below, two sources of truth for the same setting with no stated
precedence). `HostOptions.Parse`'s env-var/CLI-arg path is the single
@@ -266,13 +266,13 @@ bundled into this plan's "done").
for keeping deployment config manual rather than `IOptions`-bound.
Introduce a real `IOptions`-shaped options type later only if a
genuine code-configured setting actually needs one.
-- [ ] `HostOptions.cs` — moved in from `src/SharpMud.Host` **trimmed to
+- [x] `HostOptions.cs` — moved in from `src/SharpMud.Host` **trimmed to
`DbPath` only** (per the Repository reorganization task above, not a
straight move) — `UseTelnet`/`TelnetPort` don't come with it.
-- [ ] `PasswordHashing.cs` — moved in from `src/SharpMud.Host` per the
+- [x] `PasswordHashing.cs` — moved in from `src/SharpMud.Host` per the
Repository reorganization task above, namespace updated to
`SharpMud.Hosting`, otherwise unchanged.
-- [ ] `SessionLoop.cs` — moved in from `src/SharpMud.Host`, **converted
+- [x] `SessionLoop.cs` — moved in from `src/SharpMud.Host`, **converted
from `public static class` to a constructor-injected service class**
taking `World`/`ICommandParser`/`ICommandRegistry`/`IThingRepository`
via the constructor instead of as method parameters — **not** a
@@ -283,9 +283,9 @@ bundled into this plan's "done").
missing this sibling. `RunAsync(ISession session, Thing player,
CancellationToken ct)` is what's left on the method itself once the
rest move to the constructor.
-- [ ] New `IPlayerFactory` interface: `Thing CreatePlayer(World world,
+- [x] New `IPlayerFactory` interface: `Thing CreatePlayer(World world,
string username, string passwordHash, Thing startingRoom)`.
-- [ ] `LoginFlow.cs`/`PlayerLogin.cs` — moved in from `src/SharpMud.Host`,
+- [x] `LoginFlow.cs`/`PlayerLogin.cs` — moved in from `src/SharpMud.Host`,
**converted from `public static class` to constructor-injected
service classes taking `IThingRepository`/`IPlayerFactory`** (per the
Repository reorganization task above) — not a straight move and
@@ -295,15 +295,15 @@ bundled into this plan's "done").
a consumer's login/session handling work out of the package instead
of requiring copied sample code — the concrete gap caught in PR
review.
-- [ ] Register `LoginFlow`/`PlayerLogin` in `SharpMud.Hosting`'s DI setup
+- [x] Register `LoginFlow`/`PlayerLogin` in `SharpMud.Hosting`'s DI setup
(whatever shape that takes — `AddSharpMud(...)`-style extension or
direct `Services.AddScoped()`/etc., implementation's
call).
-- [ ] `GameLoop` registered as a `BackgroundService` (or a thin
+- [x] `GameLoop` registered as a `BackgroundService` (or a thin
`BackgroundService` wrapper around it, if `GameLoop` itself shouldn't
take a direct `Microsoft.Extensions.Hosting` dependency — decide
during implementation which project should own that coupling)
-- [ ] **Explicit owner for the shutdown-time whole-world save** — caught in
+- [x] **Explicit owner for the shutdown-time whole-world save** — caught in
PR review: today's `Program.cs` does `await
repository.SaveTreeAsync(hubArea, CancellationToken.None)` after the
session loop/listener wind down but before the final `gameLoopTask`
@@ -319,7 +319,7 @@ bundled into this plan's "done").
`SharpMud.Hosting`, not left as only a Verification-section check —
needs the world root/hub `Thing` reference, which ties to the
still-open world-builder registration point (see Open Questions).
-- [ ] **No transport wiring lives here** — `Hosting` must not reference
+- [x] **No transport wiring lives here** — `Hosting` must not reference
`SharpMud.Adapters.Telnet`/`SharpMud.Adapters.Cli` (caught in PR
review: an earlier version of this task had `Hosting` itself
instantiate `TelnetListener` and branch on a `TransportMode` enum,
@@ -330,7 +330,7 @@ bundled into this plan's "done").
registration, etc.) — see the new `SharpMud.Adapters.Telnet`/
`SharpMud.Adapters.Cli` tasks below for where the transport-specific
`BackgroundService`s actually get registered.
-- [ ] `AddSharpMudRuleset(Action register)` (or
+- [x] `AddSharpMudRuleset(Action register)` (or
equivalent) extension point for the consumer's ruleset registration
callback — **must call `BuiltinCommands.RegisterAll(registry)` itself
before invoking the consumer's callback** (caught in PR review:
@@ -342,7 +342,7 @@ bundled into this plan's "done").
registration point (name/shape TBD during implementation — needs to
express "load persisted tree, else call the consumer's builder, then
save" without hardcoding `HubWorldBuilder`).
-- [ ] `GameLoop`'s `BackgroundService` constructor-injects
+- [x] `GameLoop`'s `BackgroundService` constructor-injects
`IEnumerable` and registers whatever's resolved at
startup, rather than a second dedicated registration callback
alongside `AddSharpMudRuleset` — caught in PR review: today's
@@ -356,20 +356,20 @@ bundled into this plan's "done").
`services.AddSingleton()` for their own
ruleset-specific tickables, same DI pattern already established for
`IPlayerFactory`.
-- [ ] **Verify**: does the generic host's default `ConsoleLifetime` handle
+- [x] **Verify**: does the generic host's default `ConsoleLifetime` handle
`SIGTERM` correctly on Unix out of the box? If yes, delete the
hand-rolled `PosixSignalRegistration` code instead of porting it
into `Hosting` — don't re-solve an already-fixed problem blind to
whether it's already fixed. If no, port the existing fix forward.
-- [ ] XML doc comments on every public member per `documentation.md`
+- [x] XML doc comments on every public member per `documentation.md`
### `SharpMud.Adapters.Telnet` transport wiring (new tasks on an existing project)
-- [ ] Add a `ProjectReference` from `SharpMud.Adapters.Telnet` to
+- [x] Add a `ProjectReference` from `SharpMud.Adapters.Telnet` to
`SharpMud.Hosting` — the dependency direction flips relative to
today (`Hosting` must never reference `Adapters.Telnet`, per the
`SharpMud.Hosting` task section above).
-- [ ] Fold `HostRunner.cs`'s logic (moved in from `src/SharpMud.Host` per
+- [x] Fold `HostRunner.cs`'s logic (moved in from `src/SharpMud.Host` per
the Repository reorganization task) into an
`AddSharpMudTelnetTransport(int port)` DI extension — a
`BackgroundService` constructor-injected with `World`/
@@ -384,31 +384,31 @@ bundled into this plan's "done").
exception-isolation shape `HostRunner.HandleConnectionAsync` already
has today. Where `StartingRoom` comes from is tied to the
still-open world-builder registration point — see Open Questions.
-- [ ] XML doc comments per `documentation.md`.
+- [x] XML doc comments per `documentation.md`.
### `SharpMud.Adapters.Cli` transport wiring (new tasks on an existing project)
-- [ ] Add a `ProjectReference` from `SharpMud.Adapters.Cli` to
+- [x] Add a `ProjectReference` from `SharpMud.Adapters.Cli` to
`SharpMud.Hosting`, same reasoning as Telnet above.
-- [ ] `AddSharpMudCliTransport()` DI extension covering what today's
+- [x] `AddSharpMudCliTransport()` DI extension covering what today's
`Program.cs` CLI branch does inline: construct a `ConsoleSession`,
resolve/create the player via the injected `PlayerLogin` service
(constructor-injected `IThingRepository`/`IPlayerFactory`, not a
`createPlayer` parameter — per the `LoginFlow`/`PlayerLogin` fix
above), run `SessionLoop.RunAsync`.
-- [ ] XML doc comments per `documentation.md`.
+- [x] XML doc comments per `documentation.md`.
### `SharpMud.Persistence` split
-- [ ] Remove `Microsoft.EntityFrameworkCore.Sqlite`/
+- [x] Remove `Microsoft.EntityFrameworkCore.Sqlite`/
`SQLitePCLRaw.lib.e_sqlite3` `PackageReference`s from
`SharpMud.Persistence.csproj` — core stays provider-agnostic
(`Microsoft.EntityFrameworkCore`/`.Relational` only)
-- [ ] New `src/SharpMud.Persistence.Sqlite/` — the removed package refs,
+- [x] New `src/SharpMud.Persistence.Sqlite/` — the removed package refs,
plus `AddSharpMudSqlitePersistence(string dbPath)` extension
wrapping today's `UseSqlite(...)` call site (moved out of
`Program.cs`)
-- [ ] **Verify before building `SharpMud.Persistence.DynamoDb`**: does
+- [x] **Verify before building `SharpMud.Persistence.DynamoDb`**: does
`EntityFrameworkCore.DynamoDb` actually accept the shared
`Configurations/` classes as-is? `ThingConfiguration`/
`BehaviorConfiguration`'s `ToTable(...)` and single-property
@@ -425,43 +425,49 @@ bundled into this plan's "done").
fails at model-validation time. This is a real go/no-go check, not a
formality — do it before writing `SharpMud.Persistence.DynamoDb`'s
other tasks below.
-- [ ] New `src/SharpMud.Persistence.DynamoDb/` — references
+- [x] New `src/SharpMud.Persistence.DynamoDb/` — references
`EntityFrameworkCore.DynamoDb 10.0.0` (the current stable release,
confirmed against NuGet directly — see ADR-0006's package table),
targeting this project's `net10.0` TFM specifically since the
provider is an EF Core 10 build, not EF Core 11, equivalent
`AddSharpMudDynamoDbPersistence(...)` extension
-- [ ] Update `samples/SharpMud.Samples.Classic` to consume
+- [x] Update `samples/SharpMud.Samples.Classic` to consume
`SharpMud.Persistence.Sqlite`'s extension instead of the inline
`UseSqlite(...)` call
### Packaging metadata + CI
-- [ ] Root `Directory.Build.props` — `PackageLicenseExpression` (MIT),
+- [x] Root `Directory.Build.props` — `PackageLicenseExpression` (MIT),
`RepositoryUrl`, `Authors`, `PackageProjectUrl`, `PackageIcon`,
`PackageReadmeFile`, `GenerateDocumentationFile`, `DebugType=embedded`,
`Microsoft.SourceLink.GitHub` — matching `optimized-enums`'/
`structured-logging`'s shape exactly; no `VersionPrefix` (release-drafter
resolves the version, per ADR-0006)
-- [ ] Root `icon.png`
-- [ ] Root `LICENSE` (MIT)
-- [ ] `NOTICE.md` (or a README section) crediting WheelMUD as design
+- [x] Root `icon.png`
+- [x] Root `LICENSE` (MIT)
+- [x] `NOTICE.md` (or a README section) crediting WheelMUD as design
inspiration, per ADR-0006's License and naming section — not a legal
requirement, matches this project's existing citation discipline
-- [ ] Set `IsPackable=true` (or leave default) on every `src/` project
+- [x] Set `IsPackable=true` (or leave default) on every `src/` project
above; confirm `samples/` projects are `IsPackable=false` explicitly
so a solution-wide `dotnet pack` never emits sample packages
-- [ ] New `src/SharpMud/SharpMud.csproj` — the meta-package: no code,
- **`ProjectReference`s (not `PackageReference`s) to every other
- `SharpMud.*` project** — verified experimentally: `dotnet pack`
- automatically translates a `ProjectReference` to a packable project
- into a `` entry in the resulting `.nuspec`, at that
- project's own version, with no requirement that the referenced
+- [x] New `src/SharpMud/SharpMud.csproj` — the meta-package: no code,
+ **`ProjectReference`s (not `PackageReference`s) to `SharpMud.Engine`,
+ `SharpMud.Hosting`, and `SharpMud.Persistence`** — narrowed from an
+ initial "every other `SharpMud.*` project" scope to just these three
+ per [ADR-0007](../adr/0007-narrow-meta-package-scope.md), caught in
+ later PR review: the original all-inclusive scope silently pulled in
+ an unused DynamoDB SDK dependency and Telnet listener for every
+ consumer. `ProjectReference`, not `PackageReference`, still applies
+ for the same reason either way — verified experimentally: `dotnet
+ pack` automatically translates a `ProjectReference` to a packable
+ project into a `` entry in the resulting `.nuspec`, at
+ that project's own version, with no requirement that the referenced
package already exist on any feed. A literal `PackageReference`
instead would fail restore on the very first `dotnet pack
SharpMud.slnx` — none of the sibling packages exist on any feed
until *after* that first pack/publish — caught in PR review.
-- [ ] `.github/workflows/publish-preview.yaml` — push to `main` →
+- [x] `.github/workflows/publish-preview.yaml` — push to `main` →
`devops-templates`' `publish-preview.yml@v10.1` +
`LayeredCraft/devops-templates/.github/actions/nuget-push@v10.1` (the
full `{owner}/{repo}/{path}@{ref}` reference — a bare
@@ -473,9 +479,9 @@ bundled into this plan's "done").
packages publish as `X.Y.Z-alpha.` while this package set
is in its alpha stage; revisit this input once it graduates out of
alpha
-- [ ] `.github/workflows/publish-release.yaml` — same shape, triggered on
+- [x] `.github/workflows/publish-release.yaml` — same shape, triggered on
`release: published`
-- [ ] Multi-target `net10.0;net11.0` on every packaged `src/` project;
+- [x] Multi-target `net10.0;net11.0` on every packaged `src/` project;
**verify** the codebase actually compiles clean against `net10.0` —
if any `net11.0`-only API is in use, resolve via `#if` gating or
confirm it's acceptable to require `net11.0` after all (a real
@@ -483,19 +489,19 @@ bundled into this plan's "done").
### Documentation site (GitHub Pages)
-- [ ] New top-level `docsite/` directory (exact name TBD) — the Zensical
+- [x] New top-level `docsite/` directory (exact name TBD) — the Zensical
site source, kept separate from `docs/`'s existing ADR/plan/subsystem
content per ADR-0006's Documentation site section
-- [ ] `pyproject.toml`/`uv.lock` (Zensical + `mdformat` toolchain, matching
+- [x] `pyproject.toml`/`uv.lock` (Zensical + `mdformat` toolchain, matching
`dynamodb-efcore-provider`'s dependency set), `zensical.toml` with a
minimal curated `nav` (Home, Getting Started to start — expand as
real content lands)
-- [ ] `.github/workflows/docs.yaml` — PR builds (no deploy) + push-to-`main`
+- [x] `.github/workflows/docs.yaml` — PR builds (no deploy) + push-to-`main`
build/deploy via `actions/upload-pages-artifact`/`actions/deploy-pages`,
matching `dynamodb-efcore-provider`'s workflow shape exactly
(`uv sync --locked --all-extras --dev` → `uv run zensical build`)
- [ ] Enable GitHub Pages (Pages source: GitHub Actions) in repo settings
-- [ ] Minimal skeleton content: a home page and one real "Getting Started"
+- [x] Minimal skeleton content: a home page and one real "Getting Started"
walkthrough using the actual packages this plan produces (install
`SharpMud`, write a one-project `Ruleset` + `Program.cs`, run it) —
enough to prove the pipeline end-to-end. A full content build-out
@@ -525,9 +531,9 @@ bundled into this plan's "done").
see ADR-0006"), without rewriting the doc's current-state prose to
describe unimplemented behavior as current (per `design-decisions.md`).
**Done in the design PR** (#8).
-- [ ] `README.md` (root) — update to describe the package-based consumption
+- [x] `README.md` (root) — update to describe the package-based consumption
story once implemented, not before
-- [ ] **Update every subsystem doc whose current-state prose cites the old
+- [x] **Update every subsystem doc whose current-state prose cites the old
`src/SharpMud.Host` paths/types, not just `engine-vs-ruleset.md`/
`deployment.md`** — caught in PR review, a real gap: this task
previously listed only two files, but a direct search turns up
@@ -661,27 +667,29 @@ Modified:
## Open questions / blockers
-- Whether `EntityFrameworkCore.DynamoDb` supports EF Core's TPH
- `HasDiscriminator(...)` (used by `BehaviorConfiguration` for the
- `Behavior` subtype hierarchy) is unconfirmed — flagged in PR review.
- `ToTable(...)` and single-property `HasKey(...)` are confirmed supported
- per the provider's own docs, but the discriminator/inheritance question
- is open. Resolve via the explicit verification task in the
- `SharpMud.Persistence` split section above before assuming the shared
- `Configurations/` tree works unmodified against DynamoDB.
-- Exact shape of the world-builder registration point on the builder (how
- a consumer plugs in their own `HubWorldBuilder`-equivalent) isn't fully
- designed — implementation will need to work this out concretely, it's
- sketched but not nailed down in ADR-0006.
-- Whether `GameLoop` itself should take a direct `Microsoft.Extensions.Hosting`
- dependency (become a `BackgroundService` itself) or stay hosting-agnostic
- with a thin wrapper in `SharpMud.Hosting` — implementation-time call, not
- pre-decided.
-- Exact name for the docs-site source directory (`docsite/` used as a
- placeholder throughout this plan) isn't locked in.
+All resolved during implementation:
+
+- ~~Whether `EntityFrameworkCore.DynamoDb` supports EF Core's TPH
+ `HasDiscriminator(...)`~~ — **resolved: yes.** Confirmed directly
+ against the provider's own modeling docs
+ (`docs/modeling/single-table-design.md` in
+ `LayeredCraft/dynamodb-efcore-provider`), not just inferred — TPH
+ discriminators are supported, so the shared `Configurations/` tree works
+ unmodified against DynamoDB.
+- ~~Exact shape of the world-builder registration point~~ — **resolved:
+ implemented as `IWorldBuilder`/`IPlayerFactory` (`SharpMud.Hosting`),
+ populating `WorldContext` via `WorldLoaderHostedService`.** See
+ `docs/engine-vs-ruleset.md`.
+- ~~Whether `GameLoop` should take a direct `Microsoft.Extensions.Hosting`
+ dependency~~ — **resolved: stays hosting-agnostic.**
+ `GameLoopHostedService` (`SharpMud.Hosting`) wraps it as a thin
+ `BackgroundService`; `SharpMud.Engine` never references
+ `Microsoft.Extensions.Hosting`.
+- ~~Exact name for the docs-site source directory~~ — **resolved:
+ `docsite/`,** as used throughout this plan.
- Full docs-site content (per-package configuration reference, a
data-modeling-equivalent guide, samples walkthroughs beyond the one
- Getting Started page) is real, separate writing work — not scheduled
- against this plan, needs its own follow-up plan once the package
- mechanics this plan covers are actually done and stable enough to
- document accurately.
+ Getting Started page) is real, separate writing work — still not
+ scheduled against this plan, needs its own follow-up plan once the
+ package mechanics this plan covers have had time to prove out in
+ practice.
diff --git a/docs/plans/README.md b/docs/plans/README.md
index 9a6c341..67b935e 100644
--- a/docs/plans/README.md
+++ b/docs/plans/README.md
@@ -60,4 +60,4 @@ isn't settled yet — don't do that.
| [0002](0002-telnet-protocol-negotiation.md) | [ADR-0002](../adr/0002-telnet-protocol-negotiation.md) | Done |
| [0004](0004-session-state-machine-and-reconnect.md) | [ADR-0004](../adr/0004-session-state-machine-and-reconnect.md) | Done |
| [0005](0005-security-role-model-and-moderation-commands.md) | [ADR-0005](../adr/0005-security-role-model-and-moderation-commands.md) | Not Started |
-| [0006](0006-nuget-package-distribution.md) | [ADR-0006](../adr/0006-nuget-package-distribution.md) | Not Started |
+| [0006](0006-nuget-package-distribution.md) | [ADR-0006](../adr/0006-nuget-package-distribution.md) | Done |
diff --git a/docsite/docs/getting-started.md b/docsite/docs/getting-started.md
new file mode 100644
index 0000000..7a26f59
--- /dev/null
+++ b/docsite/docs/getting-started.md
@@ -0,0 +1,121 @@
+# Getting Started
+
+This walks through the smallest possible sharp-mud game: one room, one
+built-in command set, no custom ruleset commands yet. It uses the CLI
+transport and SQLite persistence — the fastest loop for local iteration.
+
+For a full worked example with stats, combat, and a Telnet transport, see
+[`SharpMud.Samples.Classic`](https://github.com/LayeredCraft/sharp-mud/tree/main/samples/SharpMud.Samples.Classic)
+in the sharp-mud repo.
+
+!!! warning "Alpha — not yet stable"
+ sharp-mud is pre-1.0: no SemVer compatibility guarantees between
+ releases yet, and only prerelease packages are published so far - expect
+ breaking changes between versions.
+
+## Install
+
+```bash
+dotnet add package SharpMud --prerelease
+dotnet add package SharpMud.Persistence.Sqlite --prerelease
+dotnet add package SharpMud.Adapters.Cli --prerelease
+```
+
+`SharpMud` is a meta-package pulling in `SharpMud.Engine`, `SharpMud.Hosting`,
+and `SharpMud.Persistence` — you always add a persistence *provider*
+(`.Sqlite` or `.DynamoDb`) and a transport (`SharpMud.Adapters.Cli` and/or
+`SharpMud.Adapters.Telnet`) explicitly; the meta-package doesn't include
+either (see [ADR-0007](https://github.com/LayeredCraft/sharp-mud/blob/main/docs/adr/0007-narrow-meta-package-scope.md)
+for why). `--prerelease` is required until a stable 1.0 release ships.
+
+## Build the world
+
+Every consumer implements `IWorldBuilder` to describe what a brand-new world
+looks like, and `IPlayerFactory` to describe how a new character is created:
+
+```csharp
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+
+public sealed class MyWorldBuilder : IWorldBuilder
+{
+ // Fixed, not ThingId.New() - a fresh boot needs to ask the repository
+ // "does this root already exist?" (LoadTreeAsync(RootRoomId)) using the
+ // same id every time, or a persisted world can never be found again on
+ // the next run.
+ public static readonly ThingId RootRoomId = new(Guid.Parse("00000000-0000-0000-0000-000000000001"));
+
+ public ThingId RootId => RootRoomId;
+
+ public (World World, Thing StartingRoom) Build()
+ {
+ var world = new World();
+
+ var room = new Thing { Id = RootRoomId, Name = "The Square", Description = "A quiet town square." };
+ room.Behaviors.Add(new RoomBehavior());
+ world.Register(room);
+
+ return (world, room);
+ }
+
+ public Thing FindStartingRoom(Thing root) => root;
+}
+
+public sealed class MyPlayerFactory : IPlayerFactory
+{
+ public Thing CreatePlayer(World world, string username, string passwordHash, Thing startingRoom)
+ {
+ var player = new Thing { Id = ThingId.New(), Name = username };
+ player.Behaviors.Add(new PlayerBehavior { Username = username, PasswordHash = passwordHash });
+ startingRoom.Add(player);
+ world.Register(player);
+ return player;
+ }
+}
+```
+
+## Compose the host
+
+```csharp
+using Microsoft.Extensions.Hosting;
+using SharpMud.Adapters.Cli;
+using SharpMud.Hosting;
+using SharpMud.Persistence.Sqlite;
+
+var app = SharpMudApplication.CreateBuilder(args);
+
+app.Services.AddSharpMudSqlitePersistence("./mygame.db");
+app.Services.AddSharpMudWorld();
+app.Services.AddSharpMudPlayerFactory();
+app.Services.AddSharpMudRuleset((sp, registry) => { /* register your own ICommand types here */ });
+app.Services.AddSharpMudCliTransport();
+
+var mud = app.Build();
+await mud.RunAsync();
+```
+
+`SharpMudApplication.CreateBuilder` wraps the .NET generic host
+(`Host.CreateApplicationBuilder`), so `app` is a normal
+`IHostApplicationBuilder` — standard `appsettings.json`/environment
+configuration, logging, and DI all work exactly as they do in any other
+.NET generic-host app.
+
+## Run it
+
+```bash
+dotnet run
+```
+
+The CLI transport is login-free (single local player, resolved/created as
+`"Adventurer"` on start) — you land straight in `The Square`. Built-in
+commands (`look`, movement, `quit`, etc.) work immediately, registered
+automatically by `AddSharpMudRuleset` before your own callback runs.
+`AddSharpMudTelnetTransport(port)` is the transport that uses
+`SharpMud.Hosting`'s username/password login flow, since a networked
+multi-player server needs one.
+
+To add a real ruleset — stats, combat, more rooms — see
+`SharpMud.Samples.Classic`'s `Program.cs` and `HubWorldBuilder` for a fuller
+worked example, and [ADR-0006](https://github.com/LayeredCraft/sharp-mud/blob/main/docs/adr/0006-nuget-package-distribution.md)
+for the design rationale behind this package split.
diff --git a/docsite/docs/images/icon.png b/docsite/docs/images/icon.png
new file mode 100644
index 0000000..17a036b
Binary files /dev/null and b/docsite/docs/images/icon.png differ
diff --git a/docsite/docs/index.md b/docsite/docs/index.md
new file mode 100644
index 0000000..65b8703
--- /dev/null
+++ b/docsite/docs/index.md
@@ -0,0 +1,37 @@
+# sharp-mud
+
+A modern C#/.NET engine for building MUDs (Multi-User Dungeons) — faithful to
+the genre's feel while built on current .NET architecture: the .NET generic
+host, EF Core, and a `Thing`/`Behavior` composition model instead of a
+hardcoded class hierarchy.
+
+!!! warning "Alpha — not yet stable"
+ sharp-mud is pre-1.0: no SemVer compatibility guarantees between releases
+ yet, and APIs/package boundaries/persisted-data shape can all still
+ change. It's also currently built against a preview .NET 11 SDK. Use it,
+ but expect breaking changes between versions.
+
+sharp-mud ships as a set of NuGet packages you compose into your own game:
+
+- **`SharpMud.Engine`** — `Thing`/`Behavior`, the event system, generic
+ behaviors (rooms, exits, containment, identity), the command pipeline, and
+ the global tick loop. Zero knowledge of any specific ruleset.
+- **`SharpMud.Hosting`** — composition helpers for the .NET generic host:
+ `WorldContext`, `IWorldBuilder`, `IPlayerFactory`, the session/login flow,
+ and the `AddSharpMud*` extension methods that wire it all together.
+- **`SharpMud.Persistence`** (+ **`.Sqlite`** / **`.DynamoDb`**) — EF Core
+ repositories behind a provider-agnostic `IThingRepository`.
+- **`SharpMud.Adapters.Cli`** / **`SharpMud.Adapters.Telnet`** — transport
+ implementations of `ISession`.
+- **`SharpMud`** — a meta-package pulling in `SharpMud.Engine`,
+ `SharpMud.Hosting`, and `SharpMud.Persistence`. You always add a
+ persistence provider and a transport explicitly — the meta-package
+ doesn't include either (see [ADR-0007](https://github.com/LayeredCraft/sharp-mud/blob/main/docs/adr/0007-narrow-meta-package-scope.md)).
+
+Your own game — stats, combat rules, world content, commands — is a separate
+project that references these packages the same way `SharpMud.Samples.Classic`
+(the D&D-flavored reference sample in the
+[sharp-mud repo](https://github.com/LayeredCraft/sharp-mud/tree/main/samples/SharpMud.Samples.Classic))
+does.
+
+Start with [Getting Started](getting-started.md).
diff --git a/docsite/pyproject.toml b/docsite/pyproject.toml
new file mode 100644
index 0000000..2a5b20e
--- /dev/null
+++ b/docsite/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+name = "sharp-mud-docsite"
+version = "0.1.0"
+requires-python = ">=3.14"
+dependencies = [
+ "zensical>=0.0.47",
+ "mdformat",
+ "mdformat-mkdocs",
+ "mdformat-frontmatter",
+]
diff --git a/docsite/uv.lock b/docsite/uv.lock
new file mode 100644
index 0000000..52ceffe
--- /dev/null
+++ b/docsite/uv.lock
@@ -0,0 +1,324 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "deepmerge"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "mdformat"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" },
+]
+
+[[package]]
+name = "mdformat-frontmatter"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "ruamel-yaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4d/90/ae8a3d20066295367d9048ad480bcde3afe694eb592fec9c35bbdb7d68eb/mdformat_frontmatter-2.1.2.tar.gz", hash = "sha256:9c9c3159c38407f47e567202749c74ce7680017516cd158c82400f015c6a01d1", size = 9425, upload-time = "2026-05-23T05:14:26.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/a0/20b6f53593139d8b64fa8e7f750033fbb580c096c241cd551b59907e6abe/mdformat_frontmatter-2.1.2-py3-none-any.whl", hash = "sha256:bcbb1dd7e35ce4387603acd32f2c092dd4518bbfaa2a4b801a0f29a4e69bf2ca", size = 4927, upload-time = "2026-05-23T05:14:24.747Z" },
+]
+
+[[package]]
+name = "mdformat-gfm"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" },
+]
+
+[[package]]
+name = "mdformat-mkdocs"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdformat-gfm" },
+ { name = "mdit-py-plugins" },
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e3/f7/f2a80dd80d85ee50aba1a3047faeda7cefa91bfd67e951ef14eaa43b3992/mdformat_mkdocs-5.2.1.tar.gz", hash = "sha256:f3da776b2811a6a1c71daab961335371ccbf51d45febbad8accd420153292994", size = 31877, upload-time = "2026-07-07T04:12:15.957Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/82/daa65a9a2fda6fc565b2143322a3e978c70ed92988bef2da5b90e7fc919c/mdformat_mkdocs-5.2.1-py3-none-any.whl", hash = "sha256:52be5295d1567da48c021a569ffbb70d2c731db059445a31fc1fd373999557fe", size = 43070, upload-time = "2026-07-07T04:12:14.436Z" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "11.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pymdown-extensions"
+version = "11.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "ruamel-yaml"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
+]
+
+[[package]]
+name = "sharp-mud-docsite"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdformat-frontmatter" },
+ { name = "mdformat-mkdocs" },
+ { name = "zensical" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "mdformat" },
+ { name = "mdformat-frontmatter" },
+ { name = "mdformat-mkdocs" },
+ { name = "zensical", specifier = ">=0.0.47" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
+[[package]]
+name = "zensical"
+version = "0.0.51"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "deepmerge" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "pyyaml" },
+ { name = "tomli" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" },
+ { url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" },
+]
diff --git a/docsite/zensical.toml b/docsite/zensical.toml
new file mode 100644
index 0000000..6c25f67
--- /dev/null
+++ b/docsite/zensical.toml
@@ -0,0 +1,70 @@
+[project]
+site_name = "sharp-mud"
+site_description = "A modern C#/.NET engine for building MUDs (Multi-User Dungeons)."
+site_author = "LayeredCraft"
+site_url = "https://sharp-mud.layeredcraft.dev/"
+repo_url = "https://github.com/LayeredCraft/sharp-mud"
+repo_name = "LayeredCraft/sharp-mud"
+edit_uri = "edit/main/docsite/docs/"
+copyright = "Copyright © 2026 LayeredCraft"
+docs_dir = "docs"
+
+nav = [
+ { "Home" = "index.md" },
+ { "Getting Started" = "getting-started.md" }
+]
+
+[project.markdown_extensions.attr_list]
+[project.markdown_extensions.md_in_html]
+[project.markdown_extensions.pymdownx.emoji]
+emoji_index = "zensical.extensions.emoji.twemoji"
+emoji_generator = "zensical.extensions.emoji.to_svg"
+[project.markdown_extensions.admonition]
+[project.markdown_extensions.pymdownx.details]
+[project.markdown_extensions.pymdownx.superfences]
+[project.markdown_extensions.pymdownx.highlight]
+anchor_linenums = true
+line_spans = "__span"
+pygments_lang_class = true
+[project.markdown_extensions.pymdownx.inlinehilite]
+[project.markdown_extensions.pymdownx.snippets]
+[project.markdown_extensions.toc]
+permalink = true
+permalink_title = "Anchor link to this section"
+
+[project.theme]
+logo = "images/icon.png"
+favicon = "images/icon.png"
+features = [
+ "navigation.footer",
+ "navigation.indexes",
+ "content.action.edit",
+ "content.action.view",
+ "content.code.copy",
+ "content.code.select"
+]
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme)"
+toggle.icon = "lucide/sun-moon"
+toggle.name = "Switch to light mode"
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme: light)"
+scheme = "default"
+toggle.icon = "lucide/sun"
+toggle.name = "Switch to dark mode"
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme: dark)"
+scheme = "slate"
+toggle.icon = "lucide/moon"
+toggle.name = "Switch to system preference"
+
+[project.extra]
+homepage = "https://github.com/LayeredCraft/sharp-mud"
+
+[[project.extra.social]]
+icon = "fontawesome/brands/github"
+link = "https://github.com/LayeredCraft/sharp-mud"
+name = "LayeredCraft on GitHub"
diff --git a/global.json b/global.json
index 5ec2999..ca9888e 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "11.0.100-preview.5.26302.115",
+ "version": "11.0.100-preview.6.26359.118",
"rollForward": "latestFeature"
},
"test": {
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..17a036b
Binary files /dev/null and b/icon.png differ
diff --git a/src/SharpMud.Ruleset.Classic/AttackCommand.cs b/samples/SharpMud.Samples.Classic/AttackCommand.cs
similarity index 96%
rename from src/SharpMud.Ruleset.Classic/AttackCommand.cs
rename to samples/SharpMud.Samples.Classic/AttackCommand.cs
index 118e3e8..a270776 100644
--- a/src/SharpMud.Ruleset.Classic/AttackCommand.cs
+++ b/samples/SharpMud.Samples.Classic/AttackCommand.cs
@@ -1,7 +1,7 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Commands;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
public sealed class AttackCommand(ICombatManager combatManager) : ICommand
{
diff --git a/src/SharpMud.Ruleset.Classic/CharacterClass.cs b/samples/SharpMud.Samples.Classic/CharacterClass.cs
similarity index 93%
rename from src/SharpMud.Ruleset.Classic/CharacterClass.cs
rename to samples/SharpMud.Samples.Classic/CharacterClass.cs
index 4aa0f0e..f99fe07 100644
--- a/src/SharpMud.Ruleset.Classic/CharacterClass.cs
+++ b/samples/SharpMud.Samples.Classic/CharacterClass.cs
@@ -1,6 +1,6 @@
using LayeredCraft.OptimizedEnums;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
public sealed partial class CharacterClass : OptimizedEnum
{
diff --git a/src/SharpMud.Ruleset.Classic/ClassicBehaviorMappingContributor.cs b/samples/SharpMud.Samples.Classic/ClassicBehaviorMappingContributor.cs
similarity index 93%
rename from src/SharpMud.Ruleset.Classic/ClassicBehaviorMappingContributor.cs
rename to samples/SharpMud.Samples.Classic/ClassicBehaviorMappingContributor.cs
index 58731e4..6271577 100644
--- a/src/SharpMud.Ruleset.Classic/ClassicBehaviorMappingContributor.cs
+++ b/samples/SharpMud.Samples.Classic/ClassicBehaviorMappingContributor.cs
@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using SharpMud.Persistence;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// Registers this ruleset's own EF Core mapping for its behavior types
// (StatsBehavior, CombatantBehavior) - Persistence never references
diff --git a/src/SharpMud.Ruleset.Classic/ClassicCommands.cs b/samples/SharpMud.Samples.Classic/ClassicCommands.cs
similarity index 94%
rename from src/SharpMud.Ruleset.Classic/ClassicCommands.cs
rename to samples/SharpMud.Samples.Classic/ClassicCommands.cs
index e8128c4..a1319e4 100644
--- a/src/SharpMud.Ruleset.Classic/ClassicCommands.cs
+++ b/samples/SharpMud.Samples.Classic/ClassicCommands.cs
@@ -1,7 +1,7 @@
using SharpMud.Engine.Commands;
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// Called by Host alongside SharpMud.Engine.Commands.Builtin.BuiltinCommands.RegisterAll -
// these commands depend on ruleset-specific behaviors (CombatantBehavior),
diff --git a/samples/SharpMud.Samples.Classic/ClassicPlayerFactory.cs b/samples/SharpMud.Samples.Classic/ClassicPlayerFactory.cs
new file mode 100644
index 0000000..c7b85f8
--- /dev/null
+++ b/samples/SharpMud.Samples.Classic/ClassicPlayerFactory.cs
@@ -0,0 +1,11 @@
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+
+namespace SharpMud.Samples.Classic;
+
+/// The Classic ruleset's - wraps .
+public sealed class ClassicPlayerFactory : IPlayerFactory
+{
+ public Thing CreatePlayer(World world, string username, string passwordHash, Thing startingRoom) =>
+ HubWorldBuilder.CreatePlayer(world, username, passwordHash, startingRoom);
+}
diff --git a/samples/SharpMud.Samples.Classic/ClassicWorldBuilder.cs b/samples/SharpMud.Samples.Classic/ClassicWorldBuilder.cs
new file mode 100644
index 0000000..8bf7cdb
--- /dev/null
+++ b/samples/SharpMud.Samples.Classic/ClassicWorldBuilder.cs
@@ -0,0 +1,16 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+
+namespace SharpMud.Samples.Classic;
+
+/// The Classic ruleset's - wraps for .
+public sealed class ClassicWorldBuilder : IWorldBuilder
+{
+ public ThingId RootId => HubWorldBuilder.HubAreaId;
+
+ public (World World, Thing StartingRoom) Build() => HubWorldBuilder.Build();
+
+ public Thing FindStartingRoom(Thing root) =>
+ HubWorldBuilder.FindStartingRoom(root) ?? root.Children.First(c => c.HasBehavior());
+}
diff --git a/src/SharpMud.Ruleset.Classic/CombatManager.cs b/samples/SharpMud.Samples.Classic/CombatManager.cs
similarity index 99%
rename from src/SharpMud.Ruleset.Classic/CombatManager.cs
rename to samples/SharpMud.Samples.Classic/CombatManager.cs
index a3e9059..f5306db 100644
--- a/src/SharpMud.Ruleset.Classic/CombatManager.cs
+++ b/samples/SharpMud.Samples.Classic/CombatManager.cs
@@ -5,7 +5,7 @@
using SharpMud.Engine.Sessions;
using SharpMud.Engine.Ticking;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// Registered once with IGameLoop and resolves every active encounter each
// tick - simpler Host wiring than one ITickable per encounter, and all
diff --git a/src/SharpMud.Ruleset.Classic/CombatResolver.cs b/samples/SharpMud.Samples.Classic/CombatResolver.cs
similarity index 96%
rename from src/SharpMud.Ruleset.Classic/CombatResolver.cs
rename to samples/SharpMud.Samples.Classic/CombatResolver.cs
index 00e3f9e..d7bfc5c 100644
--- a/src/SharpMud.Ruleset.Classic/CombatResolver.cs
+++ b/samples/SharpMud.Samples.Classic/CombatResolver.cs
@@ -1,6 +1,6 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// Diku/Circle-style d20-vs-AC roll (docs/combat.md decision). Level/skill
// to-hit modifiers and the exact damage formula are still open items there -
diff --git a/src/SharpMud.Ruleset.Classic/CombatantBehavior.cs b/samples/SharpMud.Samples.Classic/CombatantBehavior.cs
similarity index 95%
rename from src/SharpMud.Ruleset.Classic/CombatantBehavior.cs
rename to samples/SharpMud.Samples.Classic/CombatantBehavior.cs
index f35295c..6a21d4c 100644
--- a/src/SharpMud.Ruleset.Classic/CombatantBehavior.cs
+++ b/samples/SharpMud.Samples.Classic/CombatantBehavior.cs
@@ -1,6 +1,6 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// What used to be the ICombatant interface Player/Npc implemented - now a
// behavior any Thing can carry (a hostile plant, a turret - anything the
diff --git a/src/SharpMud.Ruleset.Classic/Configurations/CombatantBehaviorConfiguration.cs b/samples/SharpMud.Samples.Classic/Configurations/CombatantBehaviorConfiguration.cs
similarity index 86%
rename from src/SharpMud.Ruleset.Classic/Configurations/CombatantBehaviorConfiguration.cs
rename to samples/SharpMud.Samples.Classic/Configurations/CombatantBehaviorConfiguration.cs
index 4767cca..6c95798 100644
--- a/src/SharpMud.Ruleset.Classic/Configurations/CombatantBehaviorConfiguration.cs
+++ b/samples/SharpMud.Samples.Classic/Configurations/CombatantBehaviorConfiguration.cs
@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
-namespace SharpMud.Ruleset.Classic.Configurations;
+namespace SharpMud.Samples.Classic.Configurations;
public sealed class CombatantBehaviorConfiguration : IEntityTypeConfiguration
{
diff --git a/src/SharpMud.Ruleset.Classic/Configurations/StatsBehaviorConfiguration.cs b/samples/SharpMud.Samples.Classic/Configurations/StatsBehaviorConfiguration.cs
similarity index 94%
rename from src/SharpMud.Ruleset.Classic/Configurations/StatsBehaviorConfiguration.cs
rename to samples/SharpMud.Samples.Classic/Configurations/StatsBehaviorConfiguration.cs
index d33d94e..39f0ca0 100644
--- a/src/SharpMud.Ruleset.Classic/Configurations/StatsBehaviorConfiguration.cs
+++ b/samples/SharpMud.Samples.Classic/Configurations/StatsBehaviorConfiguration.cs
@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
-namespace SharpMud.Ruleset.Classic.Configurations;
+namespace SharpMud.Samples.Classic.Configurations;
public sealed class StatsBehaviorConfiguration : IEntityTypeConfiguration
{
diff --git a/src/SharpMud.Ruleset.Classic/FleeCommand.cs b/samples/SharpMud.Samples.Classic/FleeCommand.cs
similarity index 98%
rename from src/SharpMud.Ruleset.Classic/FleeCommand.cs
rename to samples/SharpMud.Samples.Classic/FleeCommand.cs
index 31b6aff..9cd727e 100644
--- a/src/SharpMud.Ruleset.Classic/FleeCommand.cs
+++ b/samples/SharpMud.Samples.Classic/FleeCommand.cs
@@ -3,7 +3,7 @@
using SharpMud.Engine.Commands.Builtin;
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
public sealed class FleeCommand(ICombatManager combatManager, IRandomSource random) : ICommand
{
diff --git a/src/SharpMud.Host/HubWorldBuilder.cs b/samples/SharpMud.Samples.Classic/HubWorldBuilder.cs
similarity index 94%
rename from src/SharpMud.Host/HubWorldBuilder.cs
rename to samples/SharpMud.Samples.Classic/HubWorldBuilder.cs
index 674bd07..0ebb98c 100644
--- a/src/SharpMud.Host/HubWorldBuilder.cs
+++ b/samples/SharpMud.Samples.Classic/HubWorldBuilder.cs
@@ -1,14 +1,13 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Core;
-using SharpMud.Ruleset.Classic;
-namespace SharpMud.Host;
+namespace SharpMud.Samples.Classic;
// Content, not engine or ruleset - mirrors WheelMUD's separate "Universe"
-// project (docs/research/wheelmud-findings.md). References Ruleset.Classic
-// directly since the cave rat/sword need ruleset-specific behaviors
-// (CombatantBehavior, WearableBehavior's EquipSlot) - only Host is allowed
-// to know about a specific ruleset (docs/engine-vs-ruleset.md).
+// project (docs/research/wheelmud-findings.md). Lives alongside the ruleset
+// code in this sample rather than a separate project, per ADR-0006 - a
+// consumer's own world content and their ruleset are the two things that
+// make their game theirs, both belong in their one project.
public static class HubWorldBuilder
{
// Fixed, not ThingId.New() - so a fresh boot can ask the repository
diff --git a/src/SharpMud.Ruleset.Classic/ICombatManager.cs b/samples/SharpMud.Samples.Classic/ICombatManager.cs
similarity index 94%
rename from src/SharpMud.Ruleset.Classic/ICombatManager.cs
rename to samples/SharpMud.Samples.Classic/ICombatManager.cs
index fd6267f..044f309 100644
--- a/src/SharpMud.Ruleset.Classic/ICombatManager.cs
+++ b/samples/SharpMud.Samples.Classic/ICombatManager.cs
@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
public sealed class CombatEncounter
{
diff --git a/src/SharpMud.Ruleset.Classic/ICombatResolver.cs b/samples/SharpMud.Samples.Classic/ICombatResolver.cs
similarity index 85%
rename from src/SharpMud.Ruleset.Classic/ICombatResolver.cs
rename to samples/SharpMud.Samples.Classic/ICombatResolver.cs
index 014e770..8102e23 100644
--- a/src/SharpMud.Ruleset.Classic/ICombatResolver.cs
+++ b/samples/SharpMud.Samples.Classic/ICombatResolver.cs
@@ -1,6 +1,6 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
public sealed record CombatRoundResult(bool Hit, int Damage, bool DefenderDefeated);
diff --git a/samples/SharpMud.Samples.Classic/Program.cs b/samples/SharpMud.Samples.Classic/Program.cs
new file mode 100644
index 0000000..50d69d8
--- /dev/null
+++ b/samples/SharpMud.Samples.Classic/Program.cs
@@ -0,0 +1,95 @@
+using LayeredCraft.StructuredLogging;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using SharpMud.Adapters.Cli;
+using SharpMud.Adapters.Telnet;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Ticking;
+using SharpMud.Hosting;
+using SharpMud.Persistence;
+using SharpMud.Persistence.Sqlite;
+using SharpMud.Samples.Classic;
+
+var builder = SharpMudApplication.CreateBuilder(args);
+
+// Non-secret configuration (Serilog levels/sinks) lives in appsettings.json,
+// with environment variables able to override it - see ADR-0003
+// (docs/adr/0003-allow-appsettingsjson-for-non-secret-config.md). Secrets
+// still never go in appsettings.json; only env vars (SharpMudHostOptions.Parse
+// below) carry those. The generic host's default builder already loads
+// appsettings.json (+ env vars, + command line) - no explicit AddJsonFile
+// call needed here.
+
+// Console sink only - this repo runs in Docker (docs/deployment.md), stdout
+// is the sink Docker already captures. See ADR-0002
+// (docs/adr/0002-telnet-protocol-negotiation.md) for why Serilog was
+// introduced. The EF Core "log every SQL command at Information" override
+// lives in appsettings.json's Serilog:MinimumLevel:Override section.
+var serilogLogger = new LoggerConfiguration()
+ .ReadFrom.Configuration(builder.Configuration)
+ .CreateLogger();
+builder.Logging.ClearProviders();
+builder.Logging.AddSerilog(serilogLogger, dispose: true);
+
+// --db-path wins over SHARPMUD_DB_PATH, same CLI-arg-over-env-var
+// precedence as --telnet/SHARPMUD_MODE below - handled here rather than in
+// SharpMudHostOptions.Parse itself, matching how transport CLI args are
+// also the sample's own composition-root decision, not Hosting's (docs/adr
+// /0006-nuget-package-distribution.md). Not a secret (see security.md) -
+// just a filesystem path - so a CLI arg carries no exposure risk env-only
+// deployment config (credentials, connection strings) would have.
+// Looked up by index, not a positional pattern match on args[0] - both
+// --db-path and --telnet need to work regardless of which comes first or
+// whether both are present at once (e.g. `--telnet 4000 --db-path
+// /data/x.db`), which a leading-args-element pattern can't express.
+var dbPathIndex = Array.IndexOf(args, "--db-path");
+var dbPathArg = dbPathIndex >= 0 && dbPathIndex + 1 < args.Length ? args[dbPathIndex + 1] : null;
+var env = new Dictionary
+{
+ ["SHARPMUD_DB_PATH"] = dbPathArg ?? Environment.GetEnvironmentVariable("SHARPMUD_DB_PATH"),
+};
+var hostOptions = SharpMudHostOptions.Parse(env);
+
+builder.Services.AddSharpMudSqlitePersistence(hostOptions.DbPath);
+builder.Services.AddSingleton();
+builder.Services.AddSharpMudWorld();
+builder.Services.AddSharpMudPlayerFactory();
+
+builder.Services.AddSingleton();
+// Registered once as ICombatManager and once as ITickable, same underlying
+// instance - CombatManager both drives the kill/flee commands and advances
+// active encounters each tick.
+builder.Services.AddSingleton(sp => new CombatManager(sp.GetRequiredService(), sp.GetRequiredService().StartingRoom));
+builder.Services.AddSingleton(sp => (ITickable)sp.GetRequiredService());
+builder.Services.AddSharpMudRuleset((sp, registry) =>
+ ClassicCommands.RegisterAll(registry, sp.GetRequiredService(), sp.GetRequiredService()));
+
+// Transport mode: SHARPMUD_MODE/SHARPMUD_TELNET_PORT/--telnet, same
+// precedence as before (CLI arg wins over env var) - this is now the
+// sample's own composition-root decision, since SharpMud.Hosting must not
+// know which transport(s) exist (docs/adr/0006-nuget-package-distribution.md).
+// Looked up by index (see --db-path above), not args[0] - --telnet must
+// still be recognized when it's not the very first arg.
+var telnetIndex = Array.IndexOf(args, "--telnet");
+var useTelnet = telnetIndex >= 0
+ || string.Equals(Environment.GetEnvironmentVariable("SHARPMUD_MODE"), "telnet", StringComparison.OrdinalIgnoreCase);
+
+if (useTelnet)
+{
+ var port = 4000;
+ if (telnetIndex >= 0 && telnetIndex + 1 < args.Length && int.TryParse(args[telnetIndex + 1], out var parsedArg))
+ port = parsedArg;
+ else if (int.TryParse(Environment.GetEnvironmentVariable("SHARPMUD_TELNET_PORT"), out var parsedEnv))
+ port = parsedEnv;
+
+ builder.Services.AddSharpMudTelnetTransport(port);
+}
+else
+{
+ builder.Services.AddSharpMudCliTransport();
+}
+
+var mud = builder.Build();
+await mud.RunAsync();
diff --git a/src/SharpMud.Ruleset.Classic/Race.cs b/samples/SharpMud.Samples.Classic/Race.cs
similarity index 95%
rename from src/SharpMud.Ruleset.Classic/Race.cs
rename to samples/SharpMud.Samples.Classic/Race.cs
index 1d0797e..397b5af 100644
--- a/src/SharpMud.Ruleset.Classic/Race.cs
+++ b/samples/SharpMud.Samples.Classic/Race.cs
@@ -1,6 +1,6 @@
using LayeredCraft.OptimizedEnums;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// LayeredCraft.OptimizedEnums instead of a plain enum specifically because
// each race will eventually carry its own stat modifiers (docs/character.md
diff --git a/samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj b/samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj
new file mode 100644
index 0000000..f02e95d
--- /dev/null
+++ b/samples/SharpMud.Samples.Classic/SharpMud.Samples.Classic.csproj
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exe
+ net11.0
+ enable
+ enable
+ false
+
+
+
diff --git a/src/SharpMud.Ruleset.Classic/StatsBehavior.cs b/samples/SharpMud.Samples.Classic/StatsBehavior.cs
similarity index 96%
rename from src/SharpMud.Ruleset.Classic/StatsBehavior.cs
rename to samples/SharpMud.Samples.Classic/StatsBehavior.cs
index e344840..b9dd839 100644
--- a/src/SharpMud.Ruleset.Classic/StatsBehavior.cs
+++ b/samples/SharpMud.Samples.Classic/StatsBehavior.cs
@@ -1,6 +1,6 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic;
+namespace SharpMud.Samples.Classic;
// The D&D-style attribute/derived-stat block from docs/character.md. Attached
// to whichever Things the ruleset wants to be a "character" - a Thing with
diff --git a/src/SharpMud.Host/appsettings.json b/samples/SharpMud.Samples.Classic/appsettings.json
similarity index 100%
rename from src/SharpMud.Host/appsettings.json
rename to samples/SharpMud.Samples.Classic/appsettings.json
diff --git a/src/SharpMud.Adapters.Cli/CliTransportBackgroundService.cs b/src/SharpMud.Adapters.Cli/CliTransportBackgroundService.cs
new file mode 100644
index 0000000..d19bc22
--- /dev/null
+++ b/src/SharpMud.Adapters.Cli/CliTransportBackgroundService.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpMud.Engine.Behaviors;
+using SharpMud.Hosting;
+
+namespace SharpMud.Adapters.Cli;
+
+// Absorbs what today's Program.cs's CLI branch does inline: a single local
+// stdin/stdout session, no login (SPEC.md), resolved/created via PlayerLogin.
+internal sealed class CliTransportBackgroundService : BackgroundService
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IHostApplicationLifetime _lifetime;
+
+ // IServiceProvider, not PlayerLogin/SessionLoop directly - see
+ // TelnetTransportBackgroundService's constructor comment for why
+ // eager constructor injection here would resolve WorldContext-dependent
+ // services before WorldLoaderHostedService.StartAsync has populated it.
+ public CliTransportBackgroundService(IServiceProvider serviceProvider, IHostApplicationLifetime lifetime)
+ {
+ _serviceProvider = serviceProvider;
+ _lifetime = lifetime;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var session = new ConsoleSession();
+ var playerLogin = _serviceProvider.GetRequiredService();
+ var player = await playerLogin.ResolveOrCreateAsync("Adventurer", stoppingToken);
+ player.FindBehavior()!.Session = session;
+
+ var sessionLoop = _serviceProvider.GetRequiredService();
+ await sessionLoop.RunAsync(session, player, stoppingToken);
+
+ // CLI mode is a single, one-shot local session (SPEC.md) - once it
+ // ends (quit or disconnect), the whole process should exit, matching
+ // the pre-refactor behavior (Program.cs used to just fall through
+ // to shutdown once this returned). Without this, IHost keeps
+ // running indefinitely (GameLoop, etc.) with no session left to
+ // serve, waiting for an external SIGINT/SIGTERM that a local CLI
+ // user has no reason to expect. Telnet's BackgroundService
+ // deliberately does NOT do this - many concurrent sessions, one
+ // ending must not stop the host.
+ if (!stoppingToken.IsCancellationRequested)
+ _lifetime.StopApplication();
+ }
+}
diff --git a/src/SharpMud.Adapters.Cli/ServiceCollectionExtensions.cs b/src/SharpMud.Adapters.Cli/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..d0a8168
--- /dev/null
+++ b/src/SharpMud.Adapters.Cli/ServiceCollectionExtensions.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SharpMud.Adapters.Cli;
+
+public static class ServiceCollectionExtensions
+{
+ /// Registers a background service that runs a single local stdin/stdout session, login-free per SPEC.md.
+ public static IServiceCollection AddSharpMudCliTransport(this IServiceCollection services)
+ {
+ services.AddHostedService();
+ return services;
+ }
+}
diff --git a/src/SharpMud.Adapters.Cli/SharpMud.Adapters.Cli.csproj b/src/SharpMud.Adapters.Cli/SharpMud.Adapters.Cli.csproj
index 0ff6987..733fbd1 100644
--- a/src/SharpMud.Adapters.Cli/SharpMud.Adapters.Cli.csproj
+++ b/src/SharpMud.Adapters.Cli/SharpMud.Adapters.Cli.csproj
@@ -2,12 +2,25 @@
+
+
+
+
+
+
+
+
+
+
- net11.0
+ net10.0;net11.0
enable
enable
+ Local stdin/stdout ISession transport for SharpMud - AddSharpMudCliTransport(), login-free single-player.
diff --git a/src/SharpMud.Adapters.Telnet/ServiceCollectionExtensions.cs b/src/SharpMud.Adapters.Telnet/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..91ab806
--- /dev/null
+++ b/src/SharpMud.Adapters.Telnet/ServiceCollectionExtensions.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SharpMud.Adapters.Telnet;
+
+public static class ServiceCollectionExtensions
+{
+ /// Registers a background service that accepts Telnet connections on and runs each through /.
+ public static IServiceCollection AddSharpMudTelnetTransport(this IServiceCollection services, int port)
+ {
+ services.AddSingleton(new TelnetTransportOptions(port));
+ services.AddHostedService();
+ return services;
+ }
+}
diff --git a/src/SharpMud.Adapters.Telnet/SharpMud.Adapters.Telnet.csproj b/src/SharpMud.Adapters.Telnet/SharpMud.Adapters.Telnet.csproj
index fdd3bcd..9e9d5d3 100644
--- a/src/SharpMud.Adapters.Telnet/SharpMud.Adapters.Telnet.csproj
+++ b/src/SharpMud.Adapters.Telnet/SharpMud.Adapters.Telnet.csproj
@@ -2,10 +2,15 @@
+
+
+
@@ -17,9 +22,10 @@
- net11.0
+ net10.0;net11.0
enable
enable
+ Raw TCP Telnet ISession transport for SharpMud - AddSharpMudTelnetTransport(port), real IAC option negotiation (Echo/NAWS), and the username/password login flow.
diff --git a/src/SharpMud.Adapters.Telnet/TelnetTransportBackgroundService.cs b/src/SharpMud.Adapters.Telnet/TelnetTransportBackgroundService.cs
new file mode 100644
index 0000000..28fa696
--- /dev/null
+++ b/src/SharpMud.Adapters.Telnet/TelnetTransportBackgroundService.cs
@@ -0,0 +1,92 @@
+using LayeredCraft.StructuredLogging;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Sessions;
+using SharpMud.Hosting;
+
+namespace SharpMud.Adapters.Telnet;
+
+// Absorbs what today's HostRunner.cs (src/SharpMud.Host) does - accepts
+// Telnet connections and, per connection, runs LoginFlow then SessionLoop -
+// but as a DI-constructed BackgroundService instead of a static class
+// manually threaded through Program.cs. See
+// docs/adr/0006-nuget-package-distribution.md for why this lives here and
+// not in SharpMud.Hosting (Hosting must not reference this project).
+internal sealed class TelnetTransportBackgroundService : BackgroundService
+{
+ private readonly TelnetTransportOptions _options;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _sessionLogger;
+ private readonly ILogger _logger;
+
+ // IServiceProvider, not LoginFlow/SessionLoop directly - the generic
+ // host resolves every registered IHostedService (constructing all of
+ // them) before calling any of their StartAsync methods. LoginFlow's
+ // own constructor is harmless, but it depends on ICommandRegistry,
+ // whose registration factory (AddSharpMudRuleset) can call into a
+ // consumer's ruleset registration code that reads
+ // WorldContext.StartingRoom - which isn't populated yet at that point.
+ // Deferring resolution to ExecuteAsync/HandleConnectionAsync (which
+ // only run after StartAsync, sequenced after
+ // WorldLoaderHostedService.StartAsync per registration order) avoids
+ // that. This is the legitimate "resolving something at runtime"
+ // exception to the no-service-locator rule in coding-standards.md.
+ public TelnetTransportBackgroundService(
+ TelnetTransportOptions options,
+ IServiceProvider serviceProvider,
+ ILogger sessionLogger,
+ ILogger logger)
+ {
+ _options = options;
+ _serviceProvider = serviceProvider;
+ _sessionLogger = sessionLogger;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var listener = new TelnetListener(_options.Port, _sessionLogger);
+ listener.Start();
+ _logger.Information("Listening for telnet connections on port {Port}", _options.Port);
+
+ var connectionTasks = new List();
+
+ try
+ {
+ await foreach (var session in listener.AcceptSessionsAsync(stoppingToken))
+ connectionTasks.Add(HandleConnectionAsync(session, stoppingToken));
+ }
+ finally
+ {
+ listener.Stop();
+ await Task.WhenAll(connectionTasks);
+ }
+ }
+
+ private async Task HandleConnectionAsync(ISession session, CancellationToken ct)
+ {
+ try
+ {
+ var loginFlow = _serviceProvider.GetRequiredService();
+ var player = await loginFlow.RunAsync(session, ct);
+ if (player is null)
+ {
+ await session.DisconnectAsync("Login failed.", ct);
+ return;
+ }
+
+ player.FindBehavior()!.Session = session;
+
+ var sessionLoop = _serviceProvider.GetRequiredService();
+ await sessionLoop.RunAsync(session, player, ct);
+ }
+ catch (Exception ex)
+ {
+ // Exception isolation per connection - one bad session must not
+ // take down the listener or other connections.
+ _logger.Error(ex, "Session error for {SessionId}", session.SessionId);
+ }
+ }
+}
diff --git a/src/SharpMud.Adapters.Telnet/TelnetTransportOptions.cs b/src/SharpMud.Adapters.Telnet/TelnetTransportOptions.cs
new file mode 100644
index 0000000..60cdcec
--- /dev/null
+++ b/src/SharpMud.Adapters.Telnet/TelnetTransportOptions.cs
@@ -0,0 +1,7 @@
+namespace SharpMud.Adapters.Telnet;
+
+// Registered as a singleton by AddSharpMudTelnetTransport - the port a
+// BackgroundService (DI-constructed, no direct call-time arguments) needs
+// isn't otherwise reachable from TelnetTransportBackgroundService's
+// constructor.
+internal sealed record TelnetTransportOptions(int Port);
diff --git a/src/SharpMud.Engine/Behaviors/NpcBehavior.cs b/src/SharpMud.Engine/Behaviors/NpcBehavior.cs
index f55fcf9..46eadff 100644
--- a/src/SharpMud.Engine/Behaviors/NpcBehavior.cs
+++ b/src/SharpMud.Engine/Behaviors/NpcBehavior.cs
@@ -4,5 +4,5 @@ namespace SharpMud.Engine.Behaviors;
// Marker only - presence means "this Thing is an NPC." No combat stats;
// those live on a ruleset-specific behavior (e.g. CombatantBehavior in
-// SharpMud.Ruleset.Classic).
+// SharpMud.Samples.Classic).
public sealed class NpcBehavior : Behavior;
diff --git a/src/SharpMud.Engine/Behaviors/WanderManager.cs b/src/SharpMud.Engine/Behaviors/WanderManager.cs
index cacc76b..4982df0 100644
--- a/src/SharpMud.Engine/Behaviors/WanderManager.cs
+++ b/src/SharpMud.Engine/Behaviors/WanderManager.cs
@@ -5,7 +5,7 @@
namespace SharpMud.Engine.Behaviors;
// Registered once with IGameLoop (mirrors CombatManager in
-// SharpMud.Ruleset.Classic) rather than one ITickable per wandering NPC -
+// SharpMud.Samples.Classic) rather than one ITickable per wandering NPC -
// simpler Host wiring, and it keeps Behaviors free of a dependency on
// IGameLoop for self-registration.
public sealed class WanderManager(IWorld world, IRandomSource random) : ITickable
diff --git a/src/SharpMud.Engine/Behaviors/WanderingBehavior.cs b/src/SharpMud.Engine/Behaviors/WanderingBehavior.cs
index 7ed3aa5..06b89af 100644
--- a/src/SharpMud.Engine/Behaviors/WanderingBehavior.cs
+++ b/src/SharpMud.Engine/Behaviors/WanderingBehavior.cs
@@ -3,7 +3,7 @@
namespace SharpMud.Engine.Behaviors;
// Presence + WanderManager (registered once with IGameLoop, like
-// CombatManager in SharpMud.Ruleset.Classic) means "this NPC randomly moves
+// CombatManager in SharpMud.Samples.Classic) means "this NPC randomly moves
// to an adjacent room some percentage of ticks." Engine-level (not
// ruleset-specific) since wandering doesn't depend on any combat/stat system.
public sealed class WanderingBehavior : Behavior
diff --git a/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs b/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs
index 1818f2f..7130feb 100644
--- a/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs
+++ b/src/SharpMud.Engine/Commands/Builtin/BuiltinCommands.cs
@@ -4,7 +4,7 @@ namespace SharpMud.Engine.Commands.Builtin;
// Ruleset-agnostic commands only - kill/attack/flee (and anything else that
// depends on a ruleset-specific behavior) register themselves separately;
-// see SharpMud.Ruleset.Classic's equivalent registration method, called by
+// see SharpMud.Samples.Classic's equivalent registration method, called by
// Host alongside this one.
public static class BuiltinCommands
{
diff --git a/src/SharpMud.Engine/Core/IStorageInitializer.cs b/src/SharpMud.Engine/Core/IStorageInitializer.cs
new file mode 100644
index 0000000..bfd631d
--- /dev/null
+++ b/src/SharpMud.Engine/Core/IStorageInitializer.cs
@@ -0,0 +1,23 @@
+namespace SharpMud.Engine.Core;
+
+///
+/// One-time storage setup (e.g. EF Core's EnsureCreatedAsync) a
+/// persistence package needs before the world can be loaded. Registered via
+/// DI (services.AddSingleton<IStorageInitializer, ...>())
+/// rather than relying on hosted-service registration order between
+/// packages, which would be fragile - the consumer always runs every
+/// registered initializer before attempting to load anything, regardless of
+/// which order the persistence/hosting packages happened to register in.
+/// Lives alongside in
+/// SharpMud.Engine.Core rather than SharpMud.Hosting - both
+/// are persistence-lifecycle abstractions a Persistence.* provider
+/// package implements, and keeping this one in Engine means providers only
+/// ever need their existing Persistence -> Engine reference, not
+/// an extra reference to Hosting (see docs/architecture.md's
+/// dependency-direction rule).
+///
+public interface IStorageInitializer
+{
+ /// Runs any one-time setup this provider needs before the world is loaded.
+ Task InitializeAsync(CancellationToken ct);
+}
diff --git a/src/SharpMud.Engine/Sessions/ConnectionState.cs b/src/SharpMud.Engine/Sessions/ConnectionState.cs
index a439856..90d8557 100644
--- a/src/SharpMud.Engine/Sessions/ConnectionState.cs
+++ b/src/SharpMud.Engine/Sessions/ConnectionState.cs
@@ -8,7 +8,7 @@ namespace SharpMud.Engine.Sessions;
///
///
/// A LayeredCraft.OptimizedEnums state machine (ADR-0004), same precedent as
-/// Race/CharacterClass in SharpMud.Ruleset.Classic - the legal
+/// Race/CharacterClass in SharpMud.Samples.Classic - the legal
/// transitions live on the enum itself via rather than being
/// re-checked at every call site that mutates PlayerBehavior.ConnectionState.
///
diff --git a/src/SharpMud.Engine/SharpMud.Engine.csproj b/src/SharpMud.Engine/SharpMud.Engine.csproj
index 50657a5..d99f156 100644
--- a/src/SharpMud.Engine/SharpMud.Engine.csproj
+++ b/src/SharpMud.Engine/SharpMud.Engine.csproj
@@ -1,9 +1,10 @@
- net11.0
+ net10.0;net11.0
enable
enable
+ Ruleset-agnostic MUD engine core: Thing/Behavior composition, the event system, the command pipeline, and the global tick loop.
@@ -14,7 +15,7 @@
+ SharpMud.Samples.Classic. -->
diff --git a/src/SharpMud.Host/HostOptions.cs b/src/SharpMud.Host/HostOptions.cs
deleted file mode 100644
index 5516184..0000000
--- a/src/SharpMud.Host/HostOptions.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace SharpMud.Host;
-
-public sealed record HostOptions(bool UseTelnet, int TelnetPort, string DbPath)
-{
- // CLI args win over env vars, which win over defaults - the usual
- // precedence for containerized apps (Dockerfile ENV sets a baseline,
- // `docker run`/orchestrator command overrides still work).
- public static HostOptions Parse(string[] args, IReadOnlyDictionary env)
- {
- var useTelnet = args is ["--telnet", ..]
- || string.Equals(env.GetValueOrDefault("SHARPMUD_MODE"), "telnet", StringComparison.OrdinalIgnoreCase);
-
- var port = 4000;
- if (args is ["--telnet", var portArg, ..] && int.TryParse(portArg, out var parsedArg))
- port = parsedArg;
- else if (int.TryParse(env.GetValueOrDefault("SHARPMUD_TELNET_PORT"), out var parsedEnv))
- port = parsedEnv;
-
- var dbPath = env.GetValueOrDefault("SHARPMUD_DB_PATH") ?? "./sharpmud.db";
-
- return new HostOptions(useTelnet, port, dbPath);
- }
-}
diff --git a/src/SharpMud.Host/HostRunner.cs b/src/SharpMud.Host/HostRunner.cs
deleted file mode 100644
index 05ae68c..0000000
--- a/src/SharpMud.Host/HostRunner.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using LayeredCraft.StructuredLogging;
-using SharpMud.Adapters.Telnet;
-using SharpMud.Engine.Behaviors;
-using SharpMud.Engine.Sessions;
-
-namespace SharpMud.Host;
-
-public static class HostRunner
-{
- public static async Task RunTelnetAsync(TelnetHostContext context, CancellationToken ct)
- {
- var listener = new TelnetListener(context.Port, context.Logger);
- listener.Start();
- context.Logger.Information("Listening for telnet connections on port {Port}", context.Port);
-
- var connectionTasks = new List();
-
- try
- {
- await foreach (var session in listener.AcceptSessionsAsync(ct))
- connectionTasks.Add(HandleConnectionAsync(session, context, ct));
- }
- finally
- {
- listener.Stop();
- await Task.WhenAll(connectionTasks);
- }
- }
-
- private static async Task HandleConnectionAsync(ISession session, TelnetHostContext context, CancellationToken ct)
- {
- try
- {
- var player = await LoginFlow.RunAsync(session, context.World, context.Repository, context.StartingRoom, ct);
- if (player is null)
- {
- await session.DisconnectAsync("Login failed.", ct);
- return;
- }
-
- player.FindBehavior()!.Session = session;
-
- await SessionLoop.RunAsync(context.World, context.Parser, context.Registry, session, player, context.Repository, ct);
- }
- catch (Exception ex)
- {
- // Exception isolation per connection - one bad session must not
- // take down the listener or other connections.
- context.Logger.Error(ex, "Session error for {SessionId}", session.SessionId);
- }
- }
-}
diff --git a/src/SharpMud.Host/Program.cs b/src/SharpMud.Host/Program.cs
deleted file mode 100644
index 23d56e4..0000000
--- a/src/SharpMud.Host/Program.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-using System.Runtime.InteropServices;
-using LayeredCraft.StructuredLogging;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Serilog;
-using SharpMud.Adapters.Cli;
-using SharpMud.Adapters.Telnet;
-using SharpMud.Engine.Behaviors;
-using SharpMud.Engine.Commands;
-using SharpMud.Engine.Commands.Builtin;
-using SharpMud.Engine.Core;
-using SharpMud.Engine.Sessions;
-using SharpMud.Engine.Ticking;
-using SharpMud.Host;
-using SharpMud.Persistence;
-using SharpMud.Ruleset.Classic;
-
-var env = new Dictionary
-{
- ["SHARPMUD_MODE"] = Environment.GetEnvironmentVariable("SHARPMUD_MODE"),
- ["SHARPMUD_TELNET_PORT"] = Environment.GetEnvironmentVariable("SHARPMUD_TELNET_PORT"),
- ["SHARPMUD_DB_PATH"] = Environment.GetEnvironmentVariable("SHARPMUD_DB_PATH"),
-};
-var hostOptions = HostOptions.Parse(args, env);
-
-// Non-secret configuration (Serilog levels/sinks) lives in appsettings.json,
-// with environment variables able to override it - see ADR-0003
-// (docs/adr/0003-allow-appsettingsjson-for-non-secret-config.md). Secrets
-// still never go in appsettings.json; only env vars (HostOptions.Parse
-// above) carry those.
-var configuration = new ConfigurationBuilder()
- .AddJsonFile("appsettings.json", optional: false)
- .AddEnvironmentVariables()
- .Build();
-
-// Console sink only - this repo runs in Docker (docs/deployment.md), stdout
-// is the sink Docker already captures. See ADR-0002
-// (docs/adr/0002-telnet-protocol-negotiation.md) for why Serilog was
-// introduced. The EF Core "log every SQL command at Information" override
-// lives in appsettings.json's Serilog:MinimumLevel:Override section.
-var serilogLogger = new LoggerConfiguration()
- .ReadFrom.Configuration(configuration)
- .CreateLogger();
-
-var services = new ServiceCollection();
-services.AddLogging(builder => builder.AddSerilog(serilogLogger, dispose: true));
-services.AddDbContextFactory(options => options.UseSqlite($"Data Source={hostOptions.DbPath}"));
-services.AddSingleton();
-services.AddSingleton();
-services.AddSingleton();
-
-await using var provider = services.BuildServiceProvider();
-
-var logger = provider.GetRequiredService>();
-
-// EnsureCreated only, never EnsureDeleted, at boot - creates the schema if
-// missing but never wipes existing data. See docs/persistence.md Schema/
-// Migrations: a genuinely changed C# model against an old .db file during
-// dev means deleting the file by hand, not an automatic wipe every startup
-// (which would defeat persistence entirely).
-await using (var dbContext = await provider.GetRequiredService>().CreateDbContextAsync())
- await dbContext.Database.EnsureCreatedAsync();
-
-var repository = provider.GetRequiredService();
-
-var loadedArea = await repository.LoadTreeAsync(HubWorldBuilder.HubAreaId, CancellationToken.None);
-
-World world;
-Thing startingRoom;
-Thing hubArea;
-
-if (loadedArea is not null)
-{
- world = new World();
- PlayerLogin.RegisterSubtree(world, loadedArea);
- hubArea = loadedArea;
- startingRoom = HubWorldBuilder.FindStartingRoom(hubArea)
- ?? hubArea.Children.First(c => c.HasBehavior());
- logger.Information("Loaded persisted world");
-}
-else
-{
- (world, startingRoom) = HubWorldBuilder.Build();
- hubArea = world.GetThing(HubWorldBuilder.HubAreaId)!;
- await repository.SaveTreeAsync(hubArea, CancellationToken.None);
- logger.Information("No persisted world found - built and saved a fresh one");
-}
-
-var random = new RandomSource();
-var combatResolver = new CombatResolver(random);
-var combatManager = new CombatManager(combatResolver, startingRoom);
-var wanderManager = new WanderManager(world, random);
-var linkdeadSweeper = new LinkdeadSweeper(world, repository);
-
-var gameLoop = new GameLoop(new GameLoopOptions());
-gameLoop.Register(combatManager);
-gameLoop.Register(wanderManager);
-gameLoop.Register(linkdeadSweeper);
-
-var registry = new CommandRegistry();
-BuiltinCommands.RegisterAll(registry);
-ClassicCommands.RegisterAll(registry, combatManager, random);
-
-var parser = provider.GetRequiredService();
-
-using var cts = new CancellationTokenSource();
-
-// PosixSignalRegistration, not Console.CancelKeyPress - CancelKeyPress only
-// ever catches SIGINT (Ctrl+C) and was observed not firing reliably without
-// a TTY attached. Critically, it never catches SIGTERM at all, which is
-// exactly what `docker stop`/Kubernetes send on a graceful shutdown - the
-// actual scenario docs/persistence.md's "save on graceful shutdown" design
-// depends on. Handling both signals here is what makes that real.
-void RequestShutdown(PosixSignalContext context)
-{
- context.Cancel = true;
- cts.Cancel();
-}
-
-using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, RequestShutdown);
-using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, RequestShutdown);
-
-var gameLoopTask = gameLoop.RunAsync(cts.Token);
-
-if (hostOptions.UseTelnet)
-{
- var telnetSessionLogger = provider.GetRequiredService>();
- var telnetHostContext = new TelnetHostContext(
- world, parser, registry, repository, startingRoom, hostOptions.TelnetPort, telnetSessionLogger);
- await HostRunner.RunTelnetAsync(telnetHostContext, cts.Token);
-}
-else
-{
- ISession session = new ConsoleSession();
- var player = await PlayerLogin.ResolveOrCreateAsync(world, repository, "Adventurer", startingRoom, cts.Token);
- player.FindBehavior()!.Session = session;
-
- await SessionLoop.RunAsync(world, parser, registry, session, player, repository, cts.Token);
-}
-
-// Whole-world snapshot - each disconnected player already saved themselves
-// (SessionLoop's finally block), but NPCs (wandering position, live combat
-// HP) aren't tied to any player session and only get captured here.
-await repository.SaveTreeAsync(hubArea, CancellationToken.None);
-
-cts.Cancel();
-try
-{
- await gameLoopTask;
-}
-catch (OperationCanceledException)
-{
- // Expected on shutdown.
-}
diff --git a/src/SharpMud.Host/SharpMud.Host.csproj b/src/SharpMud.Host/SharpMud.Host.csproj
deleted file mode 100644
index 3ecbbcb..0000000
--- a/src/SharpMud.Host/SharpMud.Host.csproj
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Exe
- net11.0
- enable
- enable
-
-
-
diff --git a/src/SharpMud.Host/TelnetHostContext.cs b/src/SharpMud.Host/TelnetHostContext.cs
deleted file mode 100644
index cd5f30f..0000000
--- a/src/SharpMud.Host/TelnetHostContext.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Microsoft.Extensions.Logging;
-using SharpMud.Adapters.Telnet;
-using SharpMud.Engine.Commands;
-using SharpMud.Engine.Core;
-
-namespace SharpMud.Host;
-
-///
-/// Groups the dependencies needs -
-/// a parameter object rather than a growing parameter list, per
-/// .agents/skills/engineering-workflow/references/coding-standards.md's
-/// 4-parameter rule (see ADR-0002, docs/adr/0002-telnet-protocol-negotiation.md,
-/// for why this was introduced now).
-///
-public sealed record TelnetHostContext(
- World World,
- ICommandParser Parser,
- ICommandRegistry Registry,
- IThingRepository Repository,
- Thing StartingRoom,
- int Port,
- ILogger Logger);
diff --git a/src/SharpMud.Hosting/GameLoopHostedService.cs b/src/SharpMud.Hosting/GameLoopHostedService.cs
new file mode 100644
index 0000000..7783860
--- /dev/null
+++ b/src/SharpMud.Hosting/GameLoopHostedService.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpMud.Engine.Ticking;
+
+namespace SharpMud.Hosting;
+
+// Runs GameLoop as a BackgroundService rather than GameLoop itself taking a
+// Microsoft.Extensions.Hosting dependency - GameLoop stays engine-agnostic
+// of hosting concerns, matching the no-Ruleset/no-Hosting-reference rule
+// for SharpMud.Engine (docs/engine-vs-ruleset.md). Registers every
+// DI-resolved ITickable at startup instead of a dedicated registration
+// callback - a consumer just does
+// services.AddSingleton() for their own
+// ruleset-specific tickables (docs/adr/0006-nuget-package-distribution.md).
+internal sealed class GameLoopHostedService : BackgroundService
+{
+ private readonly IGameLoop _gameLoop;
+ private readonly IServiceProvider _serviceProvider;
+
+ // IServiceProvider, not IEnumerable directly - the generic
+ // host resolves every registered IHostedService (constructing all of
+ // them) before calling any of their StartAsync methods, so a
+ // constructor-injected IEnumerable would resolve
+ // WanderManager/LinkdeadSweeper/a consumer's CombatManager - all of
+ // which read WorldContext.World/.StartingRoom - before
+ // WorldLoaderHostedService.StartAsync has actually populated it.
+ // Deferring resolution to ExecuteAsync (which only runs after
+ // StartAsync, sequenced after WorldLoaderHostedService per
+ // registration order) avoids that. This is the legitimate
+ // "resolving something at runtime" exception to the no-service-locator
+ // rule in coding-standards.md.
+ public GameLoopHostedService(IGameLoop gameLoop, IServiceProvider serviceProvider)
+ {
+ _gameLoop = gameLoop;
+ _serviceProvider = serviceProvider;
+ }
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ foreach (var tickable in _serviceProvider.GetServices())
+ _gameLoop.Register(tickable);
+
+ return _gameLoop.RunAsync(stoppingToken);
+ }
+}
diff --git a/src/SharpMud.Hosting/IPlayerFactory.cs b/src/SharpMud.Hosting/IPlayerFactory.cs
new file mode 100644
index 0000000..283e168
--- /dev/null
+++ b/src/SharpMud.Hosting/IPlayerFactory.cs
@@ -0,0 +1,15 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Hosting;
+
+///
+/// Creates a fresh player at character creation -
+/// registered once via DI (services.AddSingleton<IPlayerFactory,
+/// MyPlayerFactory>()) so /
+/// stay ruleset-agnostic instead of calling a
+/// specific ruleset's character-creation logic directly.
+///
+public interface IPlayerFactory
+{
+ Thing CreatePlayer(World world, string username, string passwordHash, Thing startingRoom);
+}
diff --git a/src/SharpMud.Hosting/IWorldBuilder.cs b/src/SharpMud.Hosting/IWorldBuilder.cs
new file mode 100644
index 0000000..e28d51f
--- /dev/null
+++ b/src/SharpMud.Hosting/IWorldBuilder.cs
@@ -0,0 +1,23 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Hosting;
+
+///
+/// A consumer's world content and bootstrap logic - the ruleset-agnostic
+/// generic-host equivalent of what today's HubWorldBuilder is for
+/// the Classic ruleset. Registered once via DI
+/// (services.AddSingleton<IWorldBuilder, MyWorldBuilder>());
+/// calls it during startup to load a
+/// persisted world or build a fresh one.
+///
+public interface IWorldBuilder
+{
+ /// The root 's fixed id - used to look up a previously-persisted world.
+ ThingId RootId { get; }
+
+ /// Builds a brand-new world when nothing is persisted yet, returning the world and the room new players start in.
+ (World World, Thing StartingRoom) Build();
+
+ /// Locates the starting room within a world reloaded from persistence.
+ Thing FindStartingRoom(Thing root);
+}
diff --git a/src/SharpMud.Host/LoginFlow.cs b/src/SharpMud.Hosting/LoginFlow.cs
similarity index 79%
rename from src/SharpMud.Host/LoginFlow.cs
rename to src/SharpMud.Hosting/LoginFlow.cs
index e0dd1d5..fd1d991 100644
--- a/src/SharpMud.Host/LoginFlow.cs
+++ b/src/SharpMud.Hosting/LoginFlow.cs
@@ -2,23 +2,33 @@
using SharpMud.Engine.Core;
using SharpMud.Engine.Sessions;
-namespace SharpMud.Host;
+namespace SharpMud.Hosting;
-// Classic MUD login prompt (docs/accounts-auth.md) - only used by networked
-// transports (Telnet now, SSH/WebSocket later). Local CLI stays login-free
-// per SPEC.md and never calls this.
-public static class LoginFlow
+// Classic MUD login prompt (docs/accounts-auth.md) - used by every
+// networked transport (Telnet, SSH/WebSocket later); local CLI stays
+// login-free per SPEC.md and never calls this.
+public sealed class LoginFlow
{
// Not yet tuned - docs/accounts-auth.md Open Items flags the exact
// retry/lockout policy as still undecided; this is a concrete default,
// not a considered final answer.
private const int MaxPasswordAttempts = 3;
+ private readonly WorldContext _worldContext;
+ private readonly IThingRepository _repository;
+ private readonly IPlayerFactory _playerFactory;
+
+ public LoginFlow(WorldContext worldContext, IThingRepository repository, IPlayerFactory playerFactory)
+ {
+ _worldContext = worldContext;
+ _repository = repository;
+ _playerFactory = playerFactory;
+ }
+
// Returns null if the connection should be dropped (empty input,
// disconnect mid-flow) - a failed login attempt on its own loops back to
// the username prompt rather than dropping the connection.
- public static async Task RunAsync(
- ISession session, World world, IThingRepository repository, Thing startingRoom, CancellationToken ct)
+ public async Task RunAsync(ISession session, CancellationToken ct)
{
while (true)
{
@@ -27,11 +37,11 @@ public static class LoginFlow
if (string.IsNullOrWhiteSpace(username))
return null;
- var existing = await FindAndAttachExistingAsync(world, repository, username, startingRoom, ct);
+ var existing = await FindAndAttachExistingAsync(username, ct);
var player = existing is not null
? await LoginExistingAsync(session, existing, ct)
- : await MaybeCreateAsync(session, world, username, startingRoom, repository, ct);
+ : await MaybeCreateAsync(session, username, ct);
if (player is not null)
return player;
@@ -41,15 +51,16 @@ public static class LoginFlow
}
}
- private static async Task FindAndAttachExistingAsync(
- World world, IThingRepository repository, string username, Thing startingRoom, CancellationToken ct)
+ private async Task FindAndAttachExistingAsync(string username, CancellationToken ct)
{
+ var world = _worldContext.World;
+
var alreadyLive = world.AllWithBehavior()
.FirstOrDefault(p => p.FindBehavior()!.Username == username);
if (alreadyLive is not null)
return alreadyLive;
- var loaded = await repository.FindPlayerByUsernameAsync(username, ct);
+ var loaded = await _repository.FindPlayerByUsernameAsync(username, ct);
if (loaded is null)
return null;
@@ -57,7 +68,7 @@ public static class LoginFlow
// this DB call, not the live room other players are actually in -
// attach into the real live room instead (falls back to the hub if
// that room no longer exists). See docs/persistence.md.
- var liveRoom = loaded.Parent is { } lastRoom ? world.GetThing(lastRoom.Id) ?? startingRoom : startingRoom;
+ var liveRoom = loaded.Parent is { } lastRoom ? world.GetThing(lastRoom.Id) ?? _worldContext.StartingRoom : _worldContext.StartingRoom;
liveRoom.Add(loaded);
PlayerLogin.RegisterSubtree(world, loaded);
return loaded;
@@ -122,8 +133,7 @@ public static class LoginFlow
return null;
}
- private static async Task MaybeCreateAsync(
- ISession session, World world, string username, Thing startingRoom, IThingRepository repository, CancellationToken ct)
+ private async Task MaybeCreateAsync(ISession session, string username, CancellationToken ct)
{
await session.WriteAsync("Create a new character? (y/n) ", ct);
var confirm = (await session.ReadLineAsync(ct))?.Trim();
@@ -144,12 +154,12 @@ public static class LoginFlow
return null;
}
- var player = HubWorldBuilder.CreatePlayer(world, username, PasswordHashing.Hash(password), startingRoom);
+ var player = _playerFactory.CreatePlayer(_worldContext.World, username, PasswordHashing.Hash(password), _worldContext.StartingRoom);
// Persist immediately, not just on the eventual disconnect-triggered
// save - a crash before this player's first disconnect shouldn't
// lose a freshly created login.
- await repository.SaveTreeAsync(player, ct);
+ await _repository.SaveTreeAsync(player, ct);
return player;
}
diff --git a/src/SharpMud.Host/PasswordHashing.cs b/src/SharpMud.Hosting/PasswordHashing.cs
similarity index 88%
rename from src/SharpMud.Host/PasswordHashing.cs
rename to src/SharpMud.Hosting/PasswordHashing.cs
index 55254ef..c1fb64a 100644
--- a/src/SharpMud.Host/PasswordHashing.cs
+++ b/src/SharpMud.Hosting/PasswordHashing.cs
@@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Identity;
-namespace SharpMud.Host;
+namespace SharpMud.Hosting;
// Wraps Microsoft.Extensions.Identity.Core's PasswordHasher - PBKDF2
-// with a random salt, versioned hash format. Lives in Host, not Engine -
+// with a random salt, versioned hash format. Lives in Hosting, not Engine -
// this is login-flow infrastructure, not game logic, and pulling in an
// Identity package into Engine would be an unwarranted dependency for
// something only the networked login flow needs (docs/accounts-auth.md).
diff --git a/src/SharpMud.Host/PlayerLogin.cs b/src/SharpMud.Hosting/PlayerLogin.cs
similarity index 64%
rename from src/SharpMud.Host/PlayerLogin.cs
rename to src/SharpMud.Hosting/PlayerLogin.cs
index 8148497..250f5fc 100644
--- a/src/SharpMud.Host/PlayerLogin.cs
+++ b/src/SharpMud.Hosting/PlayerLogin.cs
@@ -1,44 +1,56 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Core;
-namespace SharpMud.Host;
+namespace SharpMud.Hosting;
// Local CLI only - reconnects a persisted character if one exists for this
// name, otherwise creates one fresh, with no password check at all. CLI
// stays login-free per SPEC.md; Telnet's real username/password prompt is
// LoginFlow, not this (docs/accounts-auth.md).
-public static class PlayerLogin
+public sealed class PlayerLogin
{
// Never actually checked (CLI has no login) - a fixed placeholder so
// PlayerBehavior.PasswordHash (required) has something valid-shaped in
// it, rather than leaving a local dev character's hash empty/guessable.
private static readonly string LocalCliPasswordHash = PasswordHashing.Hash(Guid.NewGuid().ToString());
- public static async Task ResolveOrCreateAsync(
- World world, IThingRepository repository, string name, Thing startingRoom, CancellationToken ct)
+ private readonly WorldContext _worldContext;
+ private readonly IThingRepository _repository;
+ private readonly IPlayerFactory _playerFactory;
+
+ public PlayerLogin(WorldContext worldContext, IThingRepository repository, IPlayerFactory playerFactory)
+ {
+ _worldContext = worldContext;
+ _repository = repository;
+ _playerFactory = playerFactory;
+ }
+
+ public static void RegisterSubtree(World world, Thing thing)
{
+ world.Register(thing);
+ foreach (var child in thing.Children)
+ RegisterSubtree(world, child);
+ }
+
+ public async Task ResolveOrCreateAsync(string name, CancellationToken ct)
+ {
+ var world = _worldContext.World;
+
var alreadyOnline = world.AllWithBehavior().FirstOrDefault(p => p.Name == name);
if (alreadyOnline is not null)
return alreadyOnline;
- var loaded = await repository.FindPlayerByUsernameAsync(name, ct);
+ var loaded = await _repository.FindPlayerByUsernameAsync(name, ct);
if (loaded is null)
- return HubWorldBuilder.CreatePlayer(world, name, LocalCliPasswordHash, startingRoom);
+ return _playerFactory.CreatePlayer(world, name, LocalCliPasswordHash, _worldContext.StartingRoom);
// loaded.Parent is a freshly-reconstructed standalone Thing from
// this DB call, not the live room other players are actually in -
// attach into the real live room instead (falls back to the hub if
// that room no longer exists).
- var liveRoom = loaded.Parent is { } lastRoom ? world.GetThing(lastRoom.Id) ?? startingRoom : startingRoom;
+ var liveRoom = loaded.Parent is { } lastRoom ? world.GetThing(lastRoom.Id) ?? _worldContext.StartingRoom : _worldContext.StartingRoom;
liveRoom.Add(loaded);
RegisterSubtree(world, loaded);
return loaded;
}
-
- public static void RegisterSubtree(World world, Thing thing)
- {
- world.Register(thing);
- foreach (var child in thing.Children)
- RegisterSubtree(world, child);
- }
}
diff --git a/src/SharpMud.Hosting/ServiceCollectionExtensions.cs b/src/SharpMud.Hosting/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..f6ceee9
--- /dev/null
+++ b/src/SharpMud.Hosting/ServiceCollectionExtensions.cs
@@ -0,0 +1,102 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Commands.Builtin;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+using SharpMud.Engine.Ticking;
+
+namespace SharpMud.Hosting;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers a consumer's ruleset commands, plus every engine-level
+ /// built-in command (look/move/quit/inventory,
+ /// etc.) - a consumer's callback
+ /// only ever needs to add their own ruleset's commands on top, never
+ /// has to remember to call BuiltinCommands.RegisterAll itself.
+ /// The callback also receives the built
+ /// so ruleset-specific command dependencies (e.g. a combat manager)
+ /// can be resolved from DI.
+ ///
+ public static IServiceCollection AddSharpMudRuleset(this IServiceCollection services, Action registerRuleset)
+ {
+ ArgumentNullException.ThrowIfNull(registerRuleset);
+
+ services.AddSingleton(sp =>
+ {
+ var registry = new CommandRegistry();
+ BuiltinCommands.RegisterAll(registry);
+ registerRuleset(sp, registry);
+ return registry;
+ });
+
+ return services;
+ }
+
+ /// Registers a consumer's world content/bootstrap logic - see .
+ public static IServiceCollection AddSharpMudWorld(this IServiceCollection services)
+ where TWorldBuilder : class, IWorldBuilder
+ {
+ services.AddSingleton();
+ return services;
+ }
+
+ /// Registers a consumer's player-creation logic - see .
+ public static IServiceCollection AddSharpMudPlayerFactory(this IServiceCollection services)
+ where TPlayerFactory : class, IPlayerFactory
+ {
+ services.AddSingleton();
+ return services;
+ }
+
+ // Called once from SharpMudApplicationBuilder's constructor - see
+ // docs/adr/0006-nuget-package-distribution.md's SharpMud.Hosting shape.
+ // Registration order matters on both ends of the host lifecycle:
+ // - Start: WorldLoaderHostedService must run before GameLoopHostedService/
+ // any transport BackgroundService, since the generic host awaits each
+ // hosted service's StartAsync in registration order before starting
+ // the next.
+ // - Stop: the generic host stops hosted services in REVERSE registration
+ // order, so GameLoopHostedService is registered after
+ // ShutdownSaveHostedService here - GameLoop's tick loop fully quiesces
+ // (its BackgroundService.StopAsync cancels and awaits ExecuteAsync)
+ // before ShutdownSaveHostedService's StopAsync takes its snapshot,
+ // instead of racing a still-running tick that could still be mutating
+ // the world mid-save. A consumer's transport BackgroundService is
+ // registered later still (after this method returns), so it stops
+ // before either of these - no new connections/input once shutdown
+ // starts.
+ internal static IServiceCollection AddSharpMudHostingCore(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(new GameLoopOptions());
+ services.AddSingleton();
+
+ // Engine-level tickables - not ruleset-specific, so Hosting
+ // registers these itself rather than requiring every consumer to
+ // remember to. Resolved lazily (inside GameLoopHostedService's
+ // ExecuteAsync, after WorldLoaderHostedService has already
+ // populated WorldContext).
+ services.AddSingleton(sp => new WanderManager(sp.GetRequiredService().World, sp.GetRequiredService()));
+ services.AddSingleton(sp => new LinkdeadSweeper(sp.GetRequiredService().World, sp.GetRequiredService()));
+
+ // Singleton, not per-connection Transient/Scoped - all three hold
+ // no per-connection state, only injected singleton dependencies
+ // (WorldContext/parser/registry/repository/factory), so one shared
+ // instance is equivalent and avoids a DI-scope dance in every
+ // transport's connection-accept loop.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddHostedService();
+ services.AddHostedService();
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/SharpMud.Host/SessionLoop.cs b/src/SharpMud.Hosting/SessionLoop.cs
similarity index 79%
rename from src/SharpMud.Host/SessionLoop.cs
rename to src/SharpMud.Hosting/SessionLoop.cs
index 0cb2170..6e9984d 100644
--- a/src/SharpMud.Host/SessionLoop.cs
+++ b/src/SharpMud.Hosting/SessionLoop.cs
@@ -4,22 +4,33 @@
using SharpMud.Engine.Core;
using SharpMud.Engine.Sessions;
-namespace SharpMud.Host;
+namespace SharpMud.Hosting;
-// Shared by every transport (local CLI, Telnet, ...) so adding a transport
-// never touches game logic - just a new ISession implementation feeding the
-// same loop (SPEC.md transport-agnostic sessions decision).
-public static class SessionLoop
+///
+/// Per-connection read-eval loop, shared by every transport (local CLI,
+/// Telnet, ...) so adding a transport never touches game logic - just a new
+/// implementation feeding the same loop (SPEC.md
+/// transport-agnostic sessions decision).
+///
+public sealed class SessionLoop
{
- public static async Task RunAsync(
- World world,
- ICommandParser parser,
- ICommandRegistry registry,
- ISession session,
- Thing player,
- IThingRepository repository,
- CancellationToken ct)
+ private readonly WorldContext _worldContext;
+ private readonly ICommandParser _parser;
+ private readonly ICommandRegistry _registry;
+ private readonly IThingRepository _repository;
+
+ public SessionLoop(WorldContext worldContext, ICommandParser parser, ICommandRegistry registry, IThingRepository repository)
{
+ _worldContext = worldContext;
+ _parser = parser;
+ _registry = registry;
+ _repository = repository;
+ }
+
+ public async Task RunAsync(ISession session, Thing player, CancellationToken ct)
+ {
+ var world = _worldContext.World;
+
// "quit" disconnects intentionally (QuitCommand) - that path skips
// the Linkdead grace period entirely and removes the player
// immediately below, same as every disconnect used to behave before
@@ -46,7 +57,7 @@ public static async Task RunAsync(
if (input is null)
break;
- var parsed = parser.Parse(input);
+ var parsed = _parser.Parse(input);
if (parsed.Verb.Length == 0)
continue;
@@ -54,7 +65,7 @@ public static async Task RunAsync(
if (currentRoom is null)
break;
- if (!registry.TryResolve(parsed.Verb, out var command))
+ if (!_registry.TryResolve(parsed.Verb, out var command))
{
await session.WriteLineAsync("Huh?", ct);
continue;
@@ -103,7 +114,7 @@ public static async Task RunAsync(
// finally around the whole method (not just this line)
// guarantees this runs even if a read/write above threw due to
// cancellation mid-operation.
- await repository.SaveTreeAsync(player, CancellationToken.None);
+ await _repository.SaveTreeAsync(player, CancellationToken.None);
// explicitQuit's removal happens AFTER the save, not before
// (PR #1 review) - ThingRepository.SaveTreeAsync persists
diff --git a/src/SharpMud.Hosting/SharpMud.Hosting.csproj b/src/SharpMud.Hosting/SharpMud.Hosting.csproj
new file mode 100644
index 0000000..8a20b4e
--- /dev/null
+++ b/src/SharpMud.Hosting/SharpMud.Hosting.csproj
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0;net11.0
+ enable
+ enable
+ .NET generic host composition helpers for SharpMud: WorldContext, IWorldBuilder, IPlayerFactory, the login/session flow, and the AddSharpMud* DI extensions.
+
+
+
diff --git a/src/SharpMud.Hosting/SharpMudApplication.cs b/src/SharpMud.Hosting/SharpMudApplication.cs
new file mode 100644
index 0000000..075ddbe
--- /dev/null
+++ b/src/SharpMud.Hosting/SharpMudApplication.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.Hosting;
+
+namespace SharpMud.Hosting;
+
+///
+/// A running (or ready-to-run) sharp-mud host - wraps the built
+/// directly, the same relationship WebApplication
+/// has to its inner host. See
+/// docs/adr/0006-nuget-package-distribution.md.
+///
+public sealed class SharpMudApplication : IHost, IAsyncDisposable
+{
+ private readonly IHost _host;
+
+ internal SharpMudApplication(IHost host)
+ {
+ _host = host;
+ }
+
+ ///
+ public IServiceProvider Services => _host.Services;
+
+ /// Creates a new - the entry point for a consumer's own Program.cs.
+ public static SharpMudApplicationBuilder CreateBuilder(string[]? args = null) => new(args);
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken = default) => _host.StartAsync(cancellationToken);
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken = default) => _host.StopAsync(cancellationToken);
+
+ ///
+ public void Dispose() => _host.Dispose();
+
+ ///
+ public ValueTask DisposeAsync() => ((IAsyncDisposable)_host).DisposeAsync();
+}
diff --git a/src/SharpMud.Hosting/SharpMudApplicationBuilder.cs b/src/SharpMud.Hosting/SharpMudApplicationBuilder.cs
new file mode 100644
index 0000000..24e97d3
--- /dev/null
+++ b/src/SharpMud.Hosting/SharpMudApplicationBuilder.cs
@@ -0,0 +1,52 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.Metrics;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace SharpMud.Hosting;
+
+///
+/// A thin facade over - the same
+/// relationship WebApplicationBuilder has to its inner builder, not
+/// a reimplementation of hosting. See
+/// docs/adr/0006-nuget-package-distribution.md for why sharp-mud's
+/// persistent-process/
+/// execution model doesn't need anything more than that.
+///
+public sealed class SharpMudApplicationBuilder : IHostApplicationBuilder
+{
+ private readonly HostApplicationBuilder _hostBuilder;
+
+ internal SharpMudApplicationBuilder(string[]? args)
+ {
+ _hostBuilder = Host.CreateApplicationBuilder(args);
+ _hostBuilder.Services.AddSharpMudHostingCore();
+ }
+
+ ///
+ public IDictionary
+
-
-
- net11.0
+ net10.0;net11.0
enable
enable
+ Provider-agnostic EF Core repository (IThingRepository/GameDbContext) for SharpMud. Pair with SharpMud.Persistence.Sqlite or .DynamoDb for an actual storage provider.
diff --git a/src/SharpMud.Ruleset.Classic/SharpMud.Ruleset.Classic.csproj b/src/SharpMud.Ruleset.Classic/SharpMud.Ruleset.Classic.csproj
deleted file mode 100644
index 603d256..0000000
--- a/src/SharpMud.Ruleset.Classic/SharpMud.Ruleset.Classic.csproj
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- net11.0
- enable
- enable
-
-
-
diff --git a/src/SharpMud/SharpMud.csproj b/src/SharpMud/SharpMud.csproj
new file mode 100644
index 0000000..941afb0
--- /dev/null
+++ b/src/SharpMud/SharpMud.csproj
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+ net10.0;net11.0
+ enable
+ enable
+
+ false
+ $(NoWarn);NU5128
+ Meta-package for sharp-mud - pulls in SharpMud.Engine, SharpMud.Hosting, and SharpMud.Persistence. Add a persistence provider (SharpMud.Persistence.Sqlite or .DynamoDb) and a transport (SharpMud.Adapters.Cli and/or .Telnet) explicitly. See https://github.com/LayeredCraft/sharp-mud for getting started.
+
+
+
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
new file mode 100644
index 0000000..75be3c4
--- /dev/null
+++ b/tests/Directory.Build.props
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ $(NoWarn);NU1608
+
+
diff --git a/tests/SharpMud.Adapters.Cli.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Adapters.Cli.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..08b7461
--- /dev/null
+++ b/tests/SharpMud.Adapters.Cli.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,17 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace SharpMud.Adapters.Cli.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void AddSharpMudCliTransport_RegistersHostedService()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSharpMudCliTransport();
+
+ services.Should().Contain(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(CliTransportBackgroundService));
+ }
+}
diff --git a/tests/SharpMud.Adapters.Cli.Tests/SharpMud.Adapters.Cli.Tests.csproj b/tests/SharpMud.Adapters.Cli.Tests/SharpMud.Adapters.Cli.Tests.csproj
new file mode 100644
index 0000000..8fda048
--- /dev/null
+++ b/tests/SharpMud.Adapters.Cli.Tests/SharpMud.Adapters.Cli.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ enable
+ enable
+ Exe
+ SharpMud.Adapters.Cli.Tests
+ net11.0
+ false
+ true
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SharpMud.Host.Tests/xunit.runner.json b/tests/SharpMud.Adapters.Cli.Tests/xunit.runner.json
similarity index 100%
rename from tests/SharpMud.Host.Tests/xunit.runner.json
rename to tests/SharpMud.Adapters.Cli.Tests/xunit.runner.json
diff --git a/tests/SharpMud.Adapters.Telnet.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Adapters.Telnet.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..c987011
--- /dev/null
+++ b/tests/SharpMud.Adapters.Telnet.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace SharpMud.Adapters.Telnet.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void AddSharpMudTelnetTransport_RegistersHostedServiceAndOptions()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSharpMudTelnetTransport(4001);
+
+ services.Should().Contain(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(TelnetTransportBackgroundService));
+ var provider = services.BuildServiceProvider();
+ provider.GetRequiredService().Port.Should().Be(4001);
+ }
+}
diff --git a/tests/SharpMud.Adapters.Telnet.Tests/SharpMud.Adapters.Telnet.Tests.csproj b/tests/SharpMud.Adapters.Telnet.Tests/SharpMud.Adapters.Telnet.Tests.csproj
index f4e71d7..4cbe781 100644
--- a/tests/SharpMud.Adapters.Telnet.Tests/SharpMud.Adapters.Telnet.Tests.csproj
+++ b/tests/SharpMud.Adapters.Telnet.Tests/SharpMud.Adapters.Telnet.Tests.csproj
@@ -30,6 +30,7 @@
+
diff --git a/tests/SharpMud.Adapters.Telnet.Tests/TelnetListenerTests.cs b/tests/SharpMud.Adapters.Telnet.Tests/TelnetListenerTests.cs
index 0c0a2b4..6759fc2 100644
--- a/tests/SharpMud.Adapters.Telnet.Tests/TelnetListenerTests.cs
+++ b/tests/SharpMud.Adapters.Telnet.Tests/TelnetListenerTests.cs
@@ -22,6 +22,7 @@ public async Task AcceptSessionsAsync_StopsPromptly_WhenCancelledWithNoConnectio
// cts.Token IS derived from TestContext.Current.CancellationToken
// (linked above) plus a CancelAfter - xUnit1051 doesn't trace that
// and flags this line as a false positive.
+#pragma warning disable xUnit1051
var enumerationTask = Task.Run(async () =>
{
await foreach (var _ in listener.AcceptSessionsAsync(cts.Token))
@@ -29,6 +30,7 @@ public async Task AcceptSessionsAsync_StopsPromptly_WhenCancelledWithNoConnectio
// No connections expected - loop should simply end.
}
});
+#pragma warning restore xUnit1051
var completed = await Task.WhenAny(enumerationTask, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
diff --git a/tests/SharpMud.Host.Tests/HostOptionsTests.cs b/tests/SharpMud.Host.Tests/HostOptionsTests.cs
deleted file mode 100644
index 2a21a54..0000000
--- a/tests/SharpMud.Host.Tests/HostOptionsTests.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using SharpMud.Host;
-
-namespace SharpMud.Host.Tests;
-
-public sealed class HostOptionsTests
-{
- [Fact]
- public void Parse_DefaultsToCliMode_WhenNoArgsOrEnv()
- {
- var options = HostOptions.Parse([], new Dictionary());
-
- options.UseTelnet.Should().BeFalse();
- }
-
- [Fact]
- public void Parse_UsesTelnetMode_WhenTelnetArgGiven()
- {
- var options = HostOptions.Parse(["--telnet"], new Dictionary());
-
- options.UseTelnet.Should().BeTrue();
- options.TelnetPort.Should().Be(4000);
- }
-
- [Fact]
- public void Parse_UsesPortFromArgs_WhenGiven()
- {
- var options = HostOptions.Parse(["--telnet", "5555"], new Dictionary());
-
- options.TelnetPort.Should().Be(5555);
- }
-
- [Fact]
- public void Parse_UsesTelnetMode_WhenEnvVarSet()
- {
- var env = new Dictionary { ["SHARPMUD_MODE"] = "telnet" };
-
- var options = HostOptions.Parse([], env);
-
- options.UseTelnet.Should().BeTrue();
- }
-
- [Fact]
- public void Parse_UsesPortFromEnvVar_WhenArgsDoNotSpecifyPort()
- {
- var env = new Dictionary
- {
- ["SHARPMUD_MODE"] = "telnet",
- ["SHARPMUD_TELNET_PORT"] = "6001",
- };
-
- var options = HostOptions.Parse(["--telnet"], env);
-
- options.TelnetPort.Should().Be(6001);
- }
-
- [Fact]
- public void Parse_ArgsPortWinsOverEnvVarPort()
- {
- var env = new Dictionary
- {
- ["SHARPMUD_MODE"] = "telnet",
- ["SHARPMUD_TELNET_PORT"] = "6001",
- };
-
- var options = HostOptions.Parse(["--telnet", "7777"], env);
-
- options.TelnetPort.Should().Be(7777);
- }
-
- [Fact]
- public void Parse_TelnetModeIsCaseInsensitive()
- {
- var env = new Dictionary { ["SHARPMUD_MODE"] = "TELNET" };
-
- var options = HostOptions.Parse([], env);
-
- options.UseTelnet.Should().BeTrue();
- }
-}
diff --git a/tests/SharpMud.Host.Tests/LoginFlowTests.cs b/tests/SharpMud.Hosting.Tests/LoginFlowTests.cs
similarity index 82%
rename from tests/SharpMud.Host.Tests/LoginFlowTests.cs
rename to tests/SharpMud.Hosting.Tests/LoginFlowTests.cs
index 0b31f15..ee57fdf 100644
--- a/tests/SharpMud.Host.Tests/LoginFlowTests.cs
+++ b/tests/SharpMud.Hosting.Tests/LoginFlowTests.cs
@@ -1,13 +1,24 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Core;
using SharpMud.Engine.Sessions;
+using SharpMud.Hosting;
-namespace SharpMud.Host.Tests;
+namespace SharpMud.Hosting.Tests;
public sealed class LoginFlowTests
{
private static Thing MakeRoom() => new() { Id = ThingId.New(), Name = "Room" };
+ private static (LoginFlow loginFlow, IThingRepository repository) MakeLoginFlow(World world, Thing room)
+ {
+ var worldContext = new WorldContext();
+ worldContext.Initialize(world, room, room);
+ var repository = Substitute.For();
+ var playerFactory = Substitute.For();
+
+ return (new LoginFlow(worldContext, repository, playerFactory), repository);
+ }
+
[Fact]
public async Task RunAsync_ResumesCharacter_WhenLinkdeadAndPasswordCorrect()
{
@@ -23,12 +34,12 @@ public async Task RunAsync_ResumesCharacter_WhenLinkdeadAndPasswordCorrect()
room.Add(player);
world.Register(player);
- var repository = Substitute.For();
+ var (loginFlow, _) = MakeLoginFlow(world, room);
var session = Substitute.For();
session.IsConnected.Returns(true);
session.ReadLineAsync(Arg.Any()).Returns("Hero", "correct-horse");
- var result = await LoginFlow.RunAsync(session, world, repository, room, TestContext.Current.CancellationToken);
+ var result = await loginFlow.RunAsync(session, TestContext.Current.CancellationToken);
result.Should().Be(player);
playerBehavior.ConnectionState.Should().Be(ConnectionState.Playing);
@@ -52,7 +63,7 @@ public async Task RunAsync_Rejects_WhenPlayingAndAlreadyConnected()
room.Add(player);
world.Register(player);
- var repository = Substitute.For();
+ var (loginFlow, _) = MakeLoginFlow(world, room);
var session = Substitute.For();
// First loop iteration: correct password but rejected -> RunAsync
// checks IsConnected before looping again; simulate the client
@@ -60,7 +71,7 @@ public async Task RunAsync_Rejects_WhenPlayingAndAlreadyConnected()
session.IsConnected.Returns(true, false);
session.ReadLineAsync(Arg.Any()).Returns("Hero", "correct-horse");
- var result = await LoginFlow.RunAsync(session, world, repository, room, TestContext.Current.CancellationToken);
+ var result = await loginFlow.RunAsync(session, TestContext.Current.CancellationToken);
result.Should().BeNull();
playerBehavior.ConnectionState.Should().Be(ConnectionState.Playing);
@@ -82,12 +93,12 @@ public async Task RunAsync_RejectsAsExpired_WhenLinkdeadPastGraceWindow()
room.Add(player);
world.Register(player);
- var repository = Substitute.For();
+ var (loginFlow, _) = MakeLoginFlow(world, room);
var session = Substitute.For();
session.IsConnected.Returns(true, false);
session.ReadLineAsync(Arg.Any()).Returns("Hero", "correct-horse");
- var result = await LoginFlow.RunAsync(session, world, repository, room, TestContext.Current.CancellationToken);
+ var result = await loginFlow.RunAsync(session, TestContext.Current.CancellationToken);
result.Should().BeNull();
playerBehavior.ConnectionState.Should().Be(ConnectionState.Linkdead);
@@ -114,12 +125,12 @@ public async Task RunAsync_RejectsAsExpired_WhenLinkdeadThingAlreadyRemovedFromW
// lookup and this password check.
room.Remove(player);
- var repository = Substitute.For();
+ var (loginFlow, _) = MakeLoginFlow(world, room);
var session = Substitute.For();
session.IsConnected.Returns(true, false);
session.ReadLineAsync(Arg.Any()).Returns("Hero", "correct-horse");
- var result = await LoginFlow.RunAsync(session, world, repository, room, TestContext.Current.CancellationToken);
+ var result = await loginFlow.RunAsync(session, TestContext.Current.CancellationToken);
result.Should().BeNull();
await session.Received(1).WriteLineAsync("That session has expired. Please log in again.", Arg.Any());
diff --git a/tests/SharpMud.Host.Tests/PasswordHashingTests.cs b/tests/SharpMud.Hosting.Tests/PasswordHashingTests.cs
similarity index 94%
rename from tests/SharpMud.Host.Tests/PasswordHashingTests.cs
rename to tests/SharpMud.Hosting.Tests/PasswordHashingTests.cs
index f8545b0..a287b63 100644
--- a/tests/SharpMud.Host.Tests/PasswordHashingTests.cs
+++ b/tests/SharpMud.Hosting.Tests/PasswordHashingTests.cs
@@ -1,6 +1,6 @@
-using SharpMud.Host;
+using SharpMud.Hosting;
-namespace SharpMud.Host.Tests;
+namespace SharpMud.Hosting.Tests;
public sealed class PasswordHashingTests
{
diff --git a/tests/SharpMud.Hosting.Tests/PlayerLoginTests.cs b/tests/SharpMud.Hosting.Tests/PlayerLoginTests.cs
new file mode 100644
index 0000000..4cbe8fd
--- /dev/null
+++ b/tests/SharpMud.Hosting.Tests/PlayerLoginTests.cs
@@ -0,0 +1,80 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+
+namespace SharpMud.Hosting.Tests;
+
+public sealed class PlayerLoginTests
+{
+ private static Thing MakeRoom() => new() { Id = ThingId.New(), Name = "Room" };
+
+ private static (PlayerLogin playerLogin, IThingRepository repository, IPlayerFactory playerFactory) MakePlayerLogin(World world, Thing room)
+ {
+ var worldContext = new WorldContext();
+ worldContext.Initialize(world, room, room);
+ var repository = Substitute.For();
+ var playerFactory = Substitute.For();
+
+ return (new PlayerLogin(worldContext, repository, playerFactory), repository, playerFactory);
+ }
+
+ [Fact]
+ public async Task ResolveOrCreateAsync_ReturnsAlreadyOnlinePlayer_WithoutTouchingRepositoryOrFactory()
+ {
+ var world = new World();
+ var room = MakeRoom();
+ world.Register(room);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Adventurer" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "Adventurer", PasswordHash = "hash" });
+ room.Add(player);
+ world.Register(player);
+
+ var (playerLogin, repository, playerFactory) = MakePlayerLogin(world, room);
+
+ var result = await playerLogin.ResolveOrCreateAsync("Adventurer", TestContext.Current.CancellationToken);
+
+ result.Should().Be(player);
+ await repository.DidNotReceive().FindPlayerByUsernameAsync(Arg.Any(), Arg.Any());
+ playerFactory.DidNotReceive().CreatePlayer(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task ResolveOrCreateAsync_ReattachesPersistedPlayer_ToLiveStartingRoom()
+ {
+ var world = new World();
+ var room = MakeRoom();
+ world.Register(room);
+
+ var loaded = new Thing { Id = ThingId.New(), Name = "Adventurer" };
+ loaded.Behaviors.Add(new PlayerBehavior { Username = "Adventurer", PasswordHash = "hash" });
+
+ var (playerLogin, repository, playerFactory) = MakePlayerLogin(world, room);
+ repository.FindPlayerByUsernameAsync("Adventurer", Arg.Any()).Returns(loaded);
+
+ var result = await playerLogin.ResolveOrCreateAsync("Adventurer", TestContext.Current.CancellationToken);
+
+ result.Should().Be(loaded);
+ room.Children.Should().Contain(loaded);
+ world.GetThing(loaded.Id).Should().Be(loaded);
+ playerFactory.DidNotReceive().CreatePlayer(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task ResolveOrCreateAsync_CreatesFreshPlayer_ViaPlayerFactory_WhenNoneFound()
+ {
+ var world = new World();
+ var room = MakeRoom();
+ world.Register(room);
+
+ var (playerLogin, repository, playerFactory) = MakePlayerLogin(world, room);
+ repository.FindPlayerByUsernameAsync("Adventurer", Arg.Any()).Returns((Thing?)null);
+
+ var created = new Thing { Id = ThingId.New(), Name = "Adventurer" };
+ playerFactory.CreatePlayer(world, "Adventurer", Arg.Any(), room).Returns(created);
+
+ var result = await playerLogin.ResolveOrCreateAsync("Adventurer", TestContext.Current.CancellationToken);
+
+ result.Should().Be(created);
+ }
+}
diff --git a/tests/SharpMud.Hosting.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Hosting.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..830daa1
--- /dev/null
+++ b/tests/SharpMud.Hosting.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpMud.Hosting;
+
+namespace SharpMud.Hosting.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void AddSharpMudHostingCore_RegistersGameLoopAfterShutdownSave_SoGameLoopStopsFirst()
+ {
+ // The generic host stops IHostedServices in reverse registration
+ // order - GameLoopHostedService must be registered after
+ // ShutdownSaveHostedService so its tick loop fully quiesces before
+ // the shutdown snapshot is taken, not concurrently with it.
+ var services = new ServiceCollection();
+
+ services.AddSharpMudHostingCore();
+
+ var hostedServiceOrder = services
+ .Where(d => d.ServiceType == typeof(IHostedService))
+ .Select(d => d.ImplementationType)
+ .ToList();
+
+ var shutdownSaveIndex = hostedServiceOrder.IndexOf(typeof(ShutdownSaveHostedService));
+ var gameLoopIndex = hostedServiceOrder.IndexOf(typeof(GameLoopHostedService));
+
+ shutdownSaveIndex.Should().BeGreaterThanOrEqualTo(0);
+ gameLoopIndex.Should().BeGreaterThan(shutdownSaveIndex);
+ }
+}
diff --git a/tests/SharpMud.Host.Tests/SessionLoopTests.cs b/tests/SharpMud.Hosting.Tests/SessionLoopTests.cs
similarity index 75%
rename from tests/SharpMud.Host.Tests/SessionLoopTests.cs
rename to tests/SharpMud.Hosting.Tests/SessionLoopTests.cs
index f46c0d7..ee770df 100644
--- a/tests/SharpMud.Host.Tests/SessionLoopTests.cs
+++ b/tests/SharpMud.Hosting.Tests/SessionLoopTests.cs
@@ -2,12 +2,13 @@
using SharpMud.Engine.Commands;
using SharpMud.Engine.Core;
using SharpMud.Engine.Sessions;
+using SharpMud.Hosting;
-namespace SharpMud.Host.Tests;
+namespace SharpMud.Hosting.Tests;
public sealed class SessionLoopTests
{
- private static (World world, Thing room, Thing player, PlayerBehavior playerBehavior) MakePlayerInRoom()
+ private static (WorldContext worldContext, World world, Thing room, Thing player, PlayerBehavior playerBehavior) MakePlayerInRoom()
{
var world = new World();
var room = new Thing { Id = ThingId.New(), Name = "Room" };
@@ -17,13 +18,17 @@ private static (World world, Thing room, Thing player, PlayerBehavior playerBeha
room.Add(player);
world.Register(room);
world.Register(player);
- return (world, room, player, playerBehavior);
+
+ var worldContext = new WorldContext();
+ worldContext.Initialize(world, room, room);
+
+ return (worldContext, world, room, player, playerBehavior);
}
[Fact]
public async Task RunAsync_MarksLinkdead_WhenSessionDropsWithoutQuit()
{
- var (world, room, player, playerBehavior) = MakePlayerInRoom();
+ var (worldContext, world, room, player, playerBehavior) = MakePlayerInRoom();
var session = Substitute.For();
session.IsConnected.Returns(true);
session.ReadLineAsync(Arg.Any()).Returns((string?)null);
@@ -32,8 +37,9 @@ public async Task RunAsync_MarksLinkdead_WhenSessionDropsWithoutQuit()
var parser = Substitute.For();
var registry = Substitute.For();
var repository = Substitute.For();
+ var sessionLoop = new SessionLoop(worldContext, parser, registry, repository);
- await SessionLoop.RunAsync(world, parser, registry, session, player, repository, TestContext.Current.CancellationToken);
+ await sessionLoop.RunAsync(session, player, TestContext.Current.CancellationToken);
playerBehavior.ConnectionState.Should().Be(ConnectionState.Linkdead);
room.Children.Should().Contain(player);
@@ -43,7 +49,7 @@ public async Task RunAsync_MarksLinkdead_WhenSessionDropsWithoutQuit()
[Fact]
public async Task RunAsync_DoesNotClobberState_WhenNewerSessionAlreadyReconnected()
{
- var (world, room, player, playerBehavior) = MakePlayerInRoom();
+ var (worldContext, world, room, player, playerBehavior) = MakePlayerInRoom();
var oldSession = Substitute.For();
oldSession.IsConnected.Returns(true);
oldSession.ReadLineAsync(Arg.Any()).Returns((string?)null);
@@ -58,8 +64,9 @@ public async Task RunAsync_DoesNotClobberState_WhenNewerSessionAlreadyReconnecte
var parser = Substitute.For();
var registry = Substitute.For();
var repository = Substitute.For();
+ var sessionLoop = new SessionLoop(worldContext, parser, registry, repository);
- await SessionLoop.RunAsync(world, parser, registry, oldSession, player, repository, TestContext.Current.CancellationToken);
+ await sessionLoop.RunAsync(oldSession, player, TestContext.Current.CancellationToken);
// The stale disconnect must not mark the actively-reconnected
// character Linkdead or remove it from the world.
@@ -71,7 +78,7 @@ public async Task RunAsync_DoesNotClobberState_WhenNewerSessionAlreadyReconnecte
[Fact]
public async Task RunAsync_RemovesImmediately_WhenPlayerQuits()
{
- var (world, room, player, playerBehavior) = MakePlayerInRoom();
+ var (worldContext, world, room, player, playerBehavior) = MakePlayerInRoom();
var session = Substitute.For();
session.IsConnected.Returns(true, false);
session.ReadLineAsync(Arg.Any()).Returns("quit");
@@ -85,8 +92,9 @@ public async Task RunAsync_RemovesImmediately_WhenPlayerQuits()
return true;
});
var repository = Substitute.For();
+ var sessionLoop = new SessionLoop(worldContext, parser, registry, repository);
- await SessionLoop.RunAsync(world, parser, registry, session, player, repository, TestContext.Current.CancellationToken);
+ await sessionLoop.RunAsync(session, player, TestContext.Current.CancellationToken);
room.Children.Should().NotContain(player);
world.GetThing(player.Id).Should().BeNull();
@@ -100,7 +108,7 @@ public async Task RunAsync_SavesWithParentStillSet_WhenPlayerQuits()
// before - ThingRepository.SaveTreeAsync persists ParentId from
// thing.Parent at save time, so removing first would silently save
// ParentId=null and lose the room the player quit from.
- var (world, room, player, playerBehavior) = MakePlayerInRoom();
+ var (worldContext, world, room, player, playerBehavior) = MakePlayerInRoom();
var session = Substitute.For();
session.IsConnected.Returns(true, false);
session.ReadLineAsync(Arg.Any()).Returns("quit");
@@ -118,9 +126,10 @@ public async Task RunAsync_SavesWithParentStillSet_WhenPlayerQuits()
var repository = Substitute.For();
repository.SaveTreeAsync(Arg.Any(), Arg.Any())
.Returns(Task.CompletedTask)
- .AndDoes(x => parentAtSaveTime = ((Thing)x[0]).Parent);
+ .AndDoes(x => parentAtSaveTime = x.ArgAt(0).Parent);
+ var sessionLoop = new SessionLoop(worldContext, parser, registry, repository);
- await SessionLoop.RunAsync(world, parser, registry, session, player, repository, TestContext.Current.CancellationToken);
+ await sessionLoop.RunAsync(session, player, TestContext.Current.CancellationToken);
parentAtSaveTime.Should().Be(room);
}
diff --git a/tests/SharpMud.Host.Tests/SharpMud.Host.Tests.csproj b/tests/SharpMud.Hosting.Tests/SharpMud.Hosting.Tests.csproj
similarity index 89%
rename from tests/SharpMud.Host.Tests/SharpMud.Host.Tests.csproj
rename to tests/SharpMud.Hosting.Tests/SharpMud.Hosting.Tests.csproj
index 9d4b2b9..b7760f4 100644
--- a/tests/SharpMud.Host.Tests/SharpMud.Host.Tests.csproj
+++ b/tests/SharpMud.Hosting.Tests/SharpMud.Hosting.Tests.csproj
@@ -4,7 +4,7 @@
enable
enable
Exe
- SharpMud.Host.Tests
+ SharpMud.Hosting.Tests
net11.0
false
true
@@ -33,7 +33,7 @@
-
+
diff --git a/tests/SharpMud.Hosting.Tests/SharpMudHostOptionsTests.cs b/tests/SharpMud.Hosting.Tests/SharpMudHostOptionsTests.cs
new file mode 100644
index 0000000..8c2fa44
--- /dev/null
+++ b/tests/SharpMud.Hosting.Tests/SharpMudHostOptionsTests.cs
@@ -0,0 +1,24 @@
+using SharpMud.Hosting;
+
+namespace SharpMud.Hosting.Tests;
+
+public sealed class SharpMudHostOptionsTests
+{
+ [Fact]
+ public void Parse_DefaultsDbPath_WhenEnvVarNotSet()
+ {
+ var options = SharpMudHostOptions.Parse(new Dictionary());
+
+ options.DbPath.Should().Be("./sharpmud.db");
+ }
+
+ [Fact]
+ public void Parse_UsesDbPathFromEnvVar_WhenSet()
+ {
+ var env = new Dictionary { ["SHARPMUD_DB_PATH"] = "/data/sharpmud.db" };
+
+ var options = SharpMudHostOptions.Parse(env);
+
+ options.DbPath.Should().Be("/data/sharpmud.db");
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/xunit.runner.json b/tests/SharpMud.Hosting.Tests/xunit.runner.json
similarity index 100%
rename from tests/SharpMud.Ruleset.Classic.Tests/xunit.runner.json
rename to tests/SharpMud.Hosting.Tests/xunit.runner.json
diff --git a/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj b/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
index 52d5649..eb990ec 100644
--- a/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
+++ b/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
@@ -42,7 +42,7 @@
-
+
diff --git a/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs b/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
index ff70a23..6d715f5 100644
--- a/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
+++ b/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
-using SharpMud.Ruleset.Classic;
+using SharpMud.Samples.Classic;
namespace SharpMud.Persistence.Tests.TestKit;
diff --git a/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs b/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
index 0b3bf45..60e0ec6 100644
--- a/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
+++ b/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
@@ -1,7 +1,7 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Core;
using SharpMud.Persistence.Tests.TestKit;
-using SharpMud.Ruleset.Classic;
+using SharpMud.Samples.Classic;
namespace SharpMud.Persistence.Tests;
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatManagerTests.cs b/tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs
similarity index 99%
rename from tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatManagerTests.cs
rename to tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs
index 9e5ee69..6394a54 100644
--- a/tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatManagerTests.cs
+++ b/tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs
@@ -3,7 +3,7 @@
using SharpMud.Engine.Sessions;
using SharpMud.Engine.Ticking;
-namespace SharpMud.Ruleset.Classic.Tests.Combat;
+namespace SharpMud.Samples.Classic.Tests.Combat;
public sealed class CombatManagerTests
{
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatResolverTests.cs b/tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs
similarity index 98%
rename from tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatResolverTests.cs
rename to tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs
index 70d071c..0922ff8 100644
--- a/tests/SharpMud.Ruleset.Classic.Tests/Combat/CombatResolverTests.cs
+++ b/tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs
@@ -1,6 +1,6 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Ruleset.Classic.Tests.Combat;
+namespace SharpMud.Samples.Classic.Tests.Combat;
public sealed class CombatResolverTests
{
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/SharpMud.Ruleset.Classic.Tests.csproj b/tests/SharpMud.Samples.Classic.Tests/SharpMud.Samples.Classic.Tests.csproj
similarity index 82%
rename from tests/SharpMud.Ruleset.Classic.Tests/SharpMud.Ruleset.Classic.Tests.csproj
rename to tests/SharpMud.Samples.Classic.Tests/SharpMud.Samples.Classic.Tests.csproj
index 1c6508b..4c990cf 100644
--- a/tests/SharpMud.Ruleset.Classic.Tests/SharpMud.Ruleset.Classic.Tests.csproj
+++ b/tests/SharpMud.Samples.Classic.Tests/SharpMud.Samples.Classic.Tests.csproj
@@ -4,7 +4,7 @@
enable
enable
Exe
- SharpMud.Ruleset.Classic.Tests
+ SharpMud.Samples.Classic.Tests
net11.0
false
true
@@ -23,8 +23,8 @@
-
-
+
+
@@ -40,7 +40,7 @@
-
+
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs b/tests/SharpMud.Samples.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs
similarity index 85%
rename from tests/SharpMud.Ruleset.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs
rename to tests/SharpMud.Samples.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs
index f4098e0..997b3d6 100644
--- a/tests/SharpMud.Ruleset.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs
+++ b/tests/SharpMud.Samples.Classic.Tests/TestKit/Attributes/ClassicAutoDataAttribute.cs
@@ -1,7 +1,7 @@
using AutoFixture;
using AutoFixture.Xunit3;
-namespace SharpMud.Ruleset.Classic.Tests.TestKit.Attributes;
+namespace SharpMud.Samples.Classic.Tests.TestKit.Attributes;
public sealed class ClassicAutoDataAttribute() : AutoDataAttribute(CreateFixture)
{
diff --git a/tests/SharpMud.Ruleset.Classic.Tests/TestKit/BaseFixtureFactory.cs b/tests/SharpMud.Samples.Classic.Tests/TestKit/BaseFixtureFactory.cs
similarity index 92%
rename from tests/SharpMud.Ruleset.Classic.Tests/TestKit/BaseFixtureFactory.cs
rename to tests/SharpMud.Samples.Classic.Tests/TestKit/BaseFixtureFactory.cs
index a9b4469..389b005 100644
--- a/tests/SharpMud.Ruleset.Classic.Tests/TestKit/BaseFixtureFactory.cs
+++ b/tests/SharpMud.Samples.Classic.Tests/TestKit/BaseFixtureFactory.cs
@@ -1,7 +1,7 @@
using AutoFixture;
using AutoFixture.AutoNSubstitute;
-namespace SharpMud.Ruleset.Classic.Tests.TestKit;
+namespace SharpMud.Samples.Classic.Tests.TestKit;
public static class BaseFixtureFactory
{
diff --git a/tests/SharpMud.Samples.Classic.Tests/xunit.runner.json b/tests/SharpMud.Samples.Classic.Tests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/tests/SharpMud.Samples.Classic.Tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}