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 Properties => ((IHostApplicationBuilder)_hostBuilder).Properties; + + /// + public IConfigurationManager Configuration => _hostBuilder.Configuration; + + /// + public IHostEnvironment Environment => _hostBuilder.Environment; + + /// + public ILoggingBuilder Logging => _hostBuilder.Logging; + + /// + public IMetricsBuilder Metrics => _hostBuilder.Metrics; + + /// + public IServiceCollection Services => _hostBuilder.Services; + + /// + public void ConfigureContainer(IServiceProviderFactory factory, Action? configure = null) + where TContainerBuilder : notnull => + _hostBuilder.ConfigureContainer(factory, configure); + + /// Builds the configured services into a , ready to run. + public SharpMudApplication Build() => new(_hostBuilder.Build()); +} diff --git a/src/SharpMud.Hosting/SharpMudHostOptions.cs b/src/SharpMud.Hosting/SharpMudHostOptions.cs new file mode 100644 index 0000000..66e0608 --- /dev/null +++ b/src/SharpMud.Hosting/SharpMudHostOptions.cs @@ -0,0 +1,20 @@ +namespace SharpMud.Hosting; + +// Deployment config read from environment variables - stays manually +// parsed rather than IOptions-bound, per security.md's reasoning for +// keeping deployment config env-var-only. Transport selection +// (SHARPMUD_MODE/SHARPMUD_TELNET_PORT/--telnet) is not part of this type - +// it moved to the consumer's own composition root, since Hosting must not +// know which transport(s) a consumer wants (docs/adr/0006-nuget-package-distribution.md). +// Named SharpMudHostOptions, not HostOptions - the latter collides with +// Microsoft.Extensions.Hosting.HostOptions, a real BCL type every consumer +// of this package will also have in scope. +public sealed record SharpMudHostOptions(string DbPath) +{ + public static SharpMudHostOptions Parse(IReadOnlyDictionary env) + { + var dbPath = env.GetValueOrDefault("SHARPMUD_DB_PATH") ?? "./sharpmud.db"; + + return new SharpMudHostOptions(dbPath); + } +} diff --git a/src/SharpMud.Hosting/ShutdownSaveHostedService.cs b/src/SharpMud.Hosting/ShutdownSaveHostedService.cs new file mode 100644 index 0000000..b1cc42d --- /dev/null +++ b/src/SharpMud.Hosting/ShutdownSaveHostedService.cs @@ -0,0 +1,43 @@ +using LayeredCraft.StructuredLogging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpMud.Engine.Core; + +namespace SharpMud.Hosting; + +// Whole-world snapshot on graceful shutdown, replacing Program.cs's final +// repository.SaveTreeAsync(hubArea, ...) call - specifically for NPC state +// (wander position, live combat HP) that isn't tied to any player session +// and so isn't already covered by each session's own on-disconnect save +// (docs/persistence.md). A BackgroundService stopping doesn't imply a save +// happens on the way out by itself, so this needs to be explicit - +// see docs/adr/0006-nuget-package-distribution.md. +internal sealed class ShutdownSaveHostedService : IHostedService +{ + private readonly WorldContext _worldContext; + private readonly IThingRepository _repository; + private readonly ILogger _logger; + + public ShutdownSaveHostedService(WorldContext worldContext, IThingRepository repository, ILogger logger) + { + _worldContext = worldContext; + _repository = repository; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (!_worldContext.IsInitialized) + return; + + // CancellationToken.None, not the token this method receives - the + // host passes an already-cancelled/expiring token during shutdown, + // and using it here would risk aborting the save at exactly the + // moment it matters most (same reasoning as SessionLoop's + // disconnect-time save). + await _repository.SaveTreeAsync(_worldContext.Root, CancellationToken.None); + _logger.Information("Saved world on shutdown"); + } +} diff --git a/src/SharpMud.Hosting/WorldContext.cs b/src/SharpMud.Hosting/WorldContext.cs new file mode 100644 index 0000000..6784050 --- /dev/null +++ b/src/SharpMud.Hosting/WorldContext.cs @@ -0,0 +1,42 @@ +using SharpMud.Engine.Core; + +namespace SharpMud.Hosting; + +// Holds the loaded-or-built World/StartingRoom/Root once +// WorldLoaderHostedService's StartAsync has run - registered as a +// singleton and populated exactly once during startup, before any +// transport BackgroundService begins accepting connections (hosted +// services' StartAsync calls run sequentially in registration order, and +// WorldLoaderHostedService registers first - see +// ServiceCollectionExtensions.AddSharpMudHosting). Other services take +// this via constructor injection instead of World/Thing directly, since +// those values don't exist yet at DI-container-build time. +public sealed class WorldContext +{ + private World? _world; + private Thing? _startingRoom; + private Thing? _root; + + // Lets ShutdownSaveHostedService skip the shutdown-time save safely if + // the process is stopping before the world ever finished loading + // (e.g. a crash during startup) - saving a null world would just throw + // a second, more confusing exception on top of whatever caused the + // early shutdown. + public bool IsInitialized => _world is not null; + + public World World => _world + ?? throw new InvalidOperationException($"{nameof(World)} is not available yet - it's only set after {nameof(WorldLoaderHostedService)} has started."); + + public Thing StartingRoom => _startingRoom + ?? throw new InvalidOperationException($"{nameof(StartingRoom)} is not available yet - it's only set after {nameof(WorldLoaderHostedService)} has started."); + + public Thing Root => _root + ?? throw new InvalidOperationException($"{nameof(Root)} is not available yet - it's only set after {nameof(WorldLoaderHostedService)} has started."); + + internal void Initialize(World world, Thing startingRoom, Thing root) + { + _world = world; + _startingRoom = startingRoom; + _root = root; + } +} diff --git a/src/SharpMud.Hosting/WorldLoaderHostedService.cs b/src/SharpMud.Hosting/WorldLoaderHostedService.cs new file mode 100644 index 0000000..e35e895 --- /dev/null +++ b/src/SharpMud.Hosting/WorldLoaderHostedService.cs @@ -0,0 +1,66 @@ +using LayeredCraft.StructuredLogging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpMud.Engine.Core; + +namespace SharpMud.Hosting; + +// Loads a persisted world, or builds and saves a fresh one - the generic +// half of what today's Program.cs does inline, parameterized by a +// consumer's IWorldBuilder instead of hardcoding HubWorldBuilder. Must be +// registered before GameLoopHostedService and any transport +// BackgroundService (see ServiceCollectionExtensions.AddSharpMudHosting) - +// the generic host awaits each IHostedService's StartAsync in registration +// order, so this fully populates WorldContext before anything that needs +// World/StartingRoom starts running. +internal sealed class WorldLoaderHostedService : IHostedService +{ + private readonly IThingRepository _repository; + private readonly IWorldBuilder _worldBuilder; + private readonly WorldContext _worldContext; + private readonly IEnumerable _storageInitializers; + private readonly ILogger _logger; + + public WorldLoaderHostedService( + IThingRepository repository, + IWorldBuilder worldBuilder, + WorldContext worldContext, + IEnumerable storageInitializers, + ILogger logger) + { + _repository = repository; + _worldBuilder = worldBuilder; + _worldContext = worldContext; + _storageInitializers = storageInitializers; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + // Run before the load below, regardless of hosted-service + // registration order between the Hosting/Persistence packages - + // see IStorageInitializer. + foreach (var initializer in _storageInitializers) + await initializer.InitializeAsync(cancellationToken); + + var loadedRoot = await _repository.LoadTreeAsync(_worldBuilder.RootId, cancellationToken); + + if (loadedRoot is not null) + { + var world = new World(); + PlayerLogin.RegisterSubtree(world, loadedRoot); + var startingRoom = _worldBuilder.FindStartingRoom(loadedRoot); + _worldContext.Initialize(world, startingRoom, loadedRoot); + _logger.Information("Loaded persisted world"); + return; + } + + var (builtWorld, builtStartingRoom) = _worldBuilder.Build(); + var root = builtWorld.GetThing(_worldBuilder.RootId)!; + await _repository.SaveTreeAsync(root, cancellationToken); + _worldContext.Initialize(builtWorld, builtStartingRoom, root); + _logger.Information("No persisted world found - built and saved a fresh one"); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/SharpMud.Persistence.DynamoDb/ServiceCollectionExtensions.cs b/src/SharpMud.Persistence.DynamoDb/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..634af15 --- /dev/null +++ b/src/SharpMud.Persistence.DynamoDb/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Amazon.DynamoDBv2; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SharpMud.Engine.Core; +using SharpMud.Persistence; + +namespace SharpMud.Persistence.DynamoDb; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers backed by DynamoDB. + /// is optional - omit it to use the + /// standard AWS credential chain and environment-configured region; see + /// EntityFrameworkCore.DynamoDb's Client Setup docs for overriding the + /// endpoint (e.g. DynamoDB Local for development). + /// + /// + /// No IStorageInitializer is registered here - unlike SQLite, + /// this provider does not create or migrate DynamoDB tables; the + /// table(s) must already exist. + /// + public static IServiceCollection AddSharpMudDynamoDbPersistence(this IServiceCollection services, Action? configureClient = null) + { + services.AddDbContextFactory(options => options.UseDynamo(dynamoOptions => + { + if (configureClient is not null) + dynamoOptions.ConfigureDynamoDbClientConfig(configureClient); + })); + services.AddSingleton(); + + return services; + } +} diff --git a/src/SharpMud.Persistence.DynamoDb/SharpMud.Persistence.DynamoDb.csproj b/src/SharpMud.Persistence.DynamoDb/SharpMud.Persistence.DynamoDb.csproj new file mode 100644 index 0000000..7c45958 --- /dev/null +++ b/src/SharpMud.Persistence.DynamoDb/SharpMud.Persistence.DynamoDb.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + net10.0 + enable + enable + DynamoDB provider for SharpMud.Persistence - AddSharpMudDynamoDbPersistence(...) via the EntityFrameworkCore.DynamoDb provider. + + + diff --git a/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs b/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..75809c9 --- /dev/null +++ b/src/SharpMud.Persistence.Sqlite/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SharpMud.Engine.Core; +using SharpMud.Persistence; + +namespace SharpMud.Persistence.Sqlite; + +public static class ServiceCollectionExtensions +{ + /// Registers backed by SQLite at . + public static IServiceCollection AddSharpMudSqlitePersistence(this IServiceCollection services, string dbPath) + { + ArgumentNullException.ThrowIfNull(dbPath); + + services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/SharpMud.Persistence.Sqlite/SharpMud.Persistence.Sqlite.csproj b/src/SharpMud.Persistence.Sqlite/SharpMud.Persistence.Sqlite.csproj new file mode 100644 index 0000000..3c47010 --- /dev/null +++ b/src/SharpMud.Persistence.Sqlite/SharpMud.Persistence.Sqlite.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + net10.0;net11.0 + enable + enable + SQLite provider for SharpMud.Persistence - AddSharpMudSqlitePersistence(path) and schema-creation storage initialization. + + + diff --git a/src/SharpMud.Persistence.Sqlite/SqliteStorageInitializer.cs b/src/SharpMud.Persistence.Sqlite/SqliteStorageInitializer.cs new file mode 100644 index 0000000..52910fa --- /dev/null +++ b/src/SharpMud.Persistence.Sqlite/SqliteStorageInitializer.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using SharpMud.Engine.Core; +using SharpMud.Persistence; + +namespace SharpMud.Persistence.Sqlite; + +// 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). +internal sealed class SqliteStorageInitializer : IStorageInitializer +{ + private readonly IDbContextFactory _contextFactory; + + public SqliteStorageInitializer(IDbContextFactory contextFactory) + { + _contextFactory = contextFactory; + } + + public async Task InitializeAsync(CancellationToken ct) + { + await using var dbContext = await _contextFactory.CreateDbContextAsync(ct); + await dbContext.Database.EnsureCreatedAsync(ct); + } +} diff --git a/src/SharpMud.Persistence/GameDbContext.cs b/src/SharpMud.Persistence/GameDbContext.cs index c7826b0..923858a 100644 --- a/src/SharpMud.Persistence/GameDbContext.cs +++ b/src/SharpMud.Persistence/GameDbContext.cs @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Picks up every IEntityTypeConfiguration in this assembly - // Thing, the base Behavior TPH mapping, and every engine-level // behavior type. Ruleset-level behaviors register via contributors - // (SharpMud.Ruleset.Classic's own ApplyConfigurationsFromAssembly + // (SharpMud.Samples.Classic's own ApplyConfigurationsFromAssembly // call over its own assembly). modelBuilder.ApplyConfigurationsFromAssembly(typeof(GameDbContext).Assembly); diff --git a/src/SharpMud.Persistence/SharpMud.Persistence.csproj b/src/SharpMud.Persistence/SharpMud.Persistence.csproj index 04738ca..d1daed0 100644 --- a/src/SharpMud.Persistence/SharpMud.Persistence.csproj +++ b/src/SharpMud.Persistence/SharpMud.Persistence.csproj @@ -5,15 +5,18 @@ + - - - 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" +}